diff --git a/.eslintignore b/.eslintignore index cfc995e9e9aef4..416bce0a8e0567 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,6 +1,6 @@ build build-module node_modules -test/e2e/test-plugins +packages/e2e-tests/plugins vendor packages/block-serialization-spec-parser/parser.js diff --git a/.eslintrc.js b/.eslintrc.js index feced45620657e..2f4a4f05e3e772 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -19,7 +19,7 @@ const majorMinorRegExp = escapeRegExp( version.replace( /\.\d+$/, '' ) ) + '(\\. module.exports = { root: true, extends: [ - '@wordpress/eslint-config', + 'plugin:@wordpress/eslint-plugin/recommended', 'plugin:jest/recommended', ], rules: { @@ -32,77 +32,6 @@ module.exports = { selector: 'ImportDeclaration[source.value=/^@wordpress\\u002F.+\\u002F/]', message: 'Path access on WordPress dependencies is not allowed.', }, - { - selector: 'ImportDeclaration[source.value=/^api-fetch(\\u002F|$)/]', - message: 'Use @wordpress/api-fetch as import path instead.', - }, - { - selector: 'ImportDeclaration[source.value=/^blob(\\u002F|$)/]', - message: 'Use @wordpress/blob as import path instead.', - }, - { - selector: 'ImportDeclaration[source.value=/^block-serialization-spec-parser(\\u002F|$)/]', - message: 'Use @wordpress/block-serialization-spec-parser as import path instead.', - }, - { - selector: 'ImportDeclaration[source.value=/^blocks(\\u002F|$)/]', - message: 'Use @wordpress/blocks as import path instead.', - },{ - selector: 'ImportDeclaration[source.value=/^components(\\u002F|$)/]', - message: 'Use @wordpress/components as import path instead.', - }, - { - selector: 'ImportDeclaration[source.value=/^data(\\u002F|$)/]', - message: 'Use @wordpress/data as import path instead.', - }, - { - selector: 'ImportDeclaration[source.value=/^date(\\u002F|$)/]', - message: 'Use @wordpress/date as import path instead.', - }, - { - selector: 'ImportDeclaration[source.value=/^deprecated(\\u002F|$)/]', - message: 'Use @wordpress/deprecated as import path instead.', - }, - { - selector: 'ImportDeclaration[source.value=/^dom(\\u002F|$)/]', - message: 'Use @wordpress/dom as import path instead.', - }, - { - selector: 'ImportDeclaration[source.value=/^editor(\\u002F|$)/]', - message: 'Use @wordpress/editor as import path instead.', - }, - { - selector: 'ImportDeclaration[source.value=/^element(\\u002F|$)/]', - message: 'Use @wordpress/element as import path instead.', - }, - { - selector: 'ImportDeclaration[source.value=/^keycodes(\\u002F|$)/]', - message: 'Use @wordpress/keycodes as import path instead.', - }, - { - selector: 'ImportDeclaration[source.value=/^nux(\\u002F|$)/]', - message: 'Use @wordpress/nux as import path instead.', - }, - { - selector: 'ImportDeclaration[source.value=/^edit-post(\\u002F|$)/]', - message: 'Use @wordpress/edit-post as import path instead.', - }, - { - selector: 'ImportDeclaration[source.value=/^viewport(\\u002F|$)/]', - message: 'Use @wordpress/viewport as import path instead.', - }, - { - selector: 'ImportDeclaration[source.value=/^plugins(\\u002F|$)/]', - message: 'Use @wordpress/plugins as import path instead.', - }, - { - "selector": "ImportDeclaration[source.value=/^core-data$/]", - "message": "Use @wordpress/core-data as import path instead." - }, - { - "selector": "ImportDeclaration[source.value=/^block-library$/]", - "message": "Use @wordpress/block-library as import path instead." - }, { selector: 'CallExpression[callee.name="deprecated"] Property[key.name="version"][value.value=/' + majorMinorRegExp + '/]', message: 'Deprecated functions must be removed before releasing this version.', @@ -182,7 +111,7 @@ module.exports = { }, overrides: [ { - files: [ 'test/e2e/**/*.js' ], + files: [ 'packages/e2e-test*/**/*.js' ], env: { browser: true, }, diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000000..5a0454ca08e56f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,95 @@ +# Data +/packages/api-fetch @youknowriad @aduth @nerrad @mmtr +/packages/core-data @youknowriad @gziolo @aduth @nerrad @noisysocks +/packages/data @youknowriad @aduth @nerrad @coderkevin +/packages/redux-routine @youknowriad @aduth @nerrad + +# Blocks +/packages/block-library @youknowriad @gziolo @Soean @ajitbohra @jorgefilipecosta @talldan @noisysocks @notnownikki + +# Editor +/packages/annotations @youknowriad @gziolo @aduth @atimmer +/packages/autop @youknowriad @aduth +/packages/block-serialization-spec-parser @youknowriad @gziolo @aduth @dmsnell +/packages/block-serialization-default-parser @youknowriad @gziolo @aduth @dmsnell +/packages/blocks @youknowriad @gziolo @aduth @noisysocks +/packages/edit-post @youknowriad @gziolo @talldan @noisysocks +/packages/editor @youknowriad @gziolo @talldan @noisysocks +/packages/list-reusable-blocks @youknowriad @aduth @noisysocks +/packages/shortcode @youknowriad @aduth + +# Widgets +/packages/edit-widgets @youknowriad + +# Tooling +/bin @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra +/packages/babel-plugin-import-jsx-pragma @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra @nosolosw +/packages/babel-plugin-makepot @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra +/packages/babel-preset-default @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra @nosolosw +/packages/browserslist-config @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra +/packages/custom-templated-path-webpack-plugin @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra +/packages/docgen @nosolosw @mkaz +/packages/e2e-test-utils @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra +/packages/e2e-tests @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra @talldan +/packages/eslint-plugin @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra @nosolosw +/packages/jest-console @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra +/packages/jest-preset-default @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra +/packages/jest-puppeteer-axe @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra +/packages/library-export-default-webpack-plugin @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra +/packages/npm-package-json-lint-config @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra +/packages/postcss-themes @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra +/packages/scripts @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra @nosolosw @mkaz + +# UI Components +/packages/components @youknowriad @gziolo @aduth @ajitbohra @jaymanpandya @jorgefilipecosta @talldan @noisysocks @chrisvanpatten +/packages/compose @youknowriad @gziolo @aduth @ajitbohra @jaymanpandya @jorgefilipecosta @talldan @noisysocks +/packages/element @youknowriad @gziolo @aduth @ajitbohra @jaymanpandya @jorgefilipecosta @talldan @noisysocks +/packages/notices @youknowriad @gziolo @aduth @ajitbohra @jaymanpandya @jorgefilipecosta @talldan @noisysocks +/packages/nux @youknowriad @gziolo @aduth @ajitbohra @jaymanpandya @jorgefilipecosta @talldan @noisysocks +/packages/viewport @youknowriad @gziolo @aduth @ajitbohra @jaymanpandya @jorgefilipecosta @talldan @noisysocks + +# Utilities +/packages/a11y @youknowriad @gziolo @aduth +/packages/blob @youknowriad @gziolo @aduth +/packages/date @youknowriad @gziolo @aduth +/packages/deprecated @youknowriad @gziolo @aduth +/packages/dom @youknowriad @gziolo @aduth @nosolosw +/packages/dom-ready @youknowriad @gziolo @aduth +/packages/escape-html @youknowriad @gziolo @aduth +/packages/html-entities @youknowriad @gziolo @aduth +/packages/i18n @youknowriad @aduth @swissspidy +/packages/is-shallow-equal @youknowriad @gziolo @aduth +/packages/keycodes @youknowriad @gziolo @aduth @talldan +/packages/priority-queue @youknowriad @gziolo @aduth +/packages/token-list @youknowriad @gziolo @aduth +/packages/url @youknowriad @gziolo @aduth @talldan +/packages/wordcount @youknowriad @gziolo @aduth + +# Extensibility +/packages/hooks @youknowriad @gziolo @aduth @adamsilverstein +/packages/plugins @youknowriad @gziolo @aduth @adamsilverstein + +# Rich Text +/packages/format-library @youknowriad @gziolo @aduth @iseulde @jorgefilipecosta +/packages/rich-text @youknowriad @aduth @iseulde @jorgefilipecosta +/packages/editor/src/components/rich-text @youknowriad @aduth @iseulde @jorgefilipecosta + +# PHP +/lib @youknowriad @gziolo @aduth + +# Documentation +/docs @youknowriad @gziolo @chrisvanpatten @mkaz @ajitbohra @nosolosw @notnownikki + +# Styles +*.scss @chrisvanpatten + +# Native (Unowned) +*.native.js @ghost +*.android.js @ghost +*.ios.js @ghost +*.native.scss @ghost +*.android.scss @ghost +*.ios.scss @ghost + +# Project Management +/.github @youknowriad @mapk @karmatosed diff --git a/.github/ISSUE_TEMPLATE/Custom.md b/.github/ISSUE_TEMPLATE/Custom.md index 89783e5bbea906..196cdeb63305fa 100644 --- a/.github/ISSUE_TEMPLATE/Custom.md +++ b/.github/ISSUE_TEMPLATE/Custom.md @@ -1,13 +1,17 @@ --- -name: Question -about: Questions or 'how to' about Gutenberg +name: Help Request +about: Please post help requests or ‘how to’ questions in support channels first --- -If you have a question you have a few places that you can ask this: +Search first! Your issue may have already been reported. -- Support Forums: https://wordpress.org/support/plugin/gutenberg -- Handbook: https://wordpress.org/gutenberg/handbook -- https://chat.wordpress.org #core-editor +For general help requests, please post in the support forum at https://wordpress.org/support/forum/how-to-and-troubleshooting/. -If you are unable to ask in those places you can ask here, however you will get faster responses through those recommended places. +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/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a968800c43fed1..cb60c1c2f0354d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -19,3 +19,4 @@ - [ ] 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/.gitignore b/.gitignore index 5ba04948b2faf7..2fb814f9616e02 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,6 @@ build-module build-style node_modules gutenberg.zip -languages/gutenberg.pot -/languages/gutenberg-translations.php # Directories/files that may appear in your environment .DS_Store diff --git a/.travis.yml b/.travis.yml index 2dc47aa01d6422..7b87e251a25258 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ dist: trusty -language: php +language: generic services: - docker @@ -13,56 +13,89 @@ notifications: cache: directories: - $HOME/.composer/cache - - $HOME/.phpbrew + - $HOME/.jest-cache - $HOME/.npm - -before_install: - - nvm install && nvm use - - npm install npm -g + - $HOME/.nvm/.cache + - $HOME/.phpbrew branches: only: - master +before_install: + - nvm install + jobs: include: - - stage: test + - name: JS unit tests env: WP_VERSION=latest + before_install: + - nvm install --latest-npm + install: + - npm ci script: - - npm install || exit 1 - - npm run ci || exit 1 + - npm run build + - npm run lint + - npm run check-local-changes + - npm run check-licenses + - npm run test-unit -- --ci --maxWorkers=2 --cacheDirectory="$HOME/.jest-cache" - - stage: test + - name: PHP unit tests (Docker) env: WP_VERSION=latest DOCKER=true script: - ./bin/run-wp-unit-tests.sh - - stage: test + - name: PHP unit tests (PHP 5.6) + language: php php: 5.6 env: WP_VERSION=latest script: - ./bin/run-wp-unit-tests.sh if: branch = master and type != "pull_request" - - stage: test - php: 7.1 + - name: PHP unit tests (PHP 5.3) env: WP_VERSION=latest SWITCH_TO_PHP=5.3 script: - ./bin/run-wp-unit-tests.sh if: branch = master and type != "pull_request" - - stage: test - php: 7.1 + - name: PHP unit tests (PHP 5.2) env: WP_VERSION=latest SWITCH_TO_PHP=5.2 script: - ./bin/run-wp-unit-tests.sh - - stage: test - env: WP_VERSION=latest + - name: E2E tests (Admin with plugins) (1/2) + env: WP_VERSION=latest POPULAR_PLUGINS=true + install: + - ./bin/setup-local-env.sh + script: + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests + - npm run build + - npm run test-e2e -- --ci --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 2 == 0' < ~/.jest-e2e-tests ) + + - name: E2E tests (Admin with plugins) (2/2) + env: WP_VERSION=latest POPULAR_PLUGINS=true + install: + - ./bin/setup-local-env.sh + script: + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests + - npm run build + - npm run test-e2e -- --ci --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 2 == 1' < ~/.jest-e2e-tests ) + + - name: E2E tests (Author without plugins) (1/2) + env: WP_VERSION=latest E2E_ROLE=author + install: + - ./bin/setup-local-env.sh script: - - ./bin/run-e2e-tests.sh || exit 1 + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests + - npm run build + - npm run test-e2e -- --ci --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 2 == 0' < ~/.jest-e2e-tests ) - - stage: test + - name: E2E tests (Author without plugins) (2/2) env: WP_VERSION=latest E2E_ROLE=author + install: + - ./bin/setup-local-env.sh script: - - ./bin/run-e2e-tests.sh || exit 1 + - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests + - npm run build + - npm run test-e2e -- --ci --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 2 == 1' < ~/.jest-e2e-tests ) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4ccb59aa2df443..576e78b27a3eeb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,12 +1,14 @@ # 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. +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). +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 read our [Contributing Policy](CONTRIBUTING.md) (you're here already!) and our [Handbook](https://wordpress.org/gutenberg/handbook/). If you have any questions on any of these, please open an issue so we can help clarify them. +Before contributing, we encourage you to review the [Contributor Handbook](https://wordpress.org/gutenberg/handbook/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. +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. ## Getting Started @@ -14,7 +16,7 @@ Gutenberg is a Node.js-based project, built primarily in JavaScript. The easiest way to get started (on MacOS, Linux, or Windows 10 with the Linux Subsystem) is by running the Local Environment setup script, `./bin/setup-local-env.sh`. This will check if you have everything installed and updated, and help you download any extra tools you need. -For other version of Windows, or if you prefer to set things up manually, be sure to have Node.js installed first. You should be running a Node version matching the [current active LTS release](https://github.com/nodejs/Release#release-schedule) or newer for this plugin to work correctly. You can check your Node.js version by typing `node -v` in the Terminal prompt. +For another version of Windows, or if you prefer to set things up manually, be sure to have Node.js installed first. You should be running a Node version matching the [current active LTS release](https://github.com/nodejs/Release#release-schedule) or newer for this plugin to work correctly. You can check your Node.js version by typing `node -v` in the Terminal prompt. If you have an incompatible version of Node in your development environment, you can use [nvm](https://github.com/creationix/nvm) to change node versions on the command line: @@ -23,7 +25,7 @@ npx nvm install npx nvm use ``` -You should also have the latest release of [npm installed][npm]. npm is a separate project from Node.js and is updated frequently. If you've just installed Node.js which includes a version of npm within the installation you most likely will need to also update your npm installation. To update npm, type this into your terminal: `npm install npm@latest -g` +You also should have the latest release of [npm installed][npm]. npm is a separate project from Node.js and is updated frequently. If you've just installed Node.js which includes a version of npm within the installation you most likely will need also to update your npm installation. To update npm, type this into your terminal: `npm install npm@latest -g` To test the plugin, or to contribute to it, you can clone this repository and build the plugin files using Node. How you do that depends on whether you're developing locally or uploading the plugin to a remote host. @@ -45,10 +47,10 @@ Then, run a setup script to check if docker and node are configured properly and **If you're developing themes, or core WordPress functionality alongside Gutenberg**, you can make the WordPress files accessible in `wordpress/` by following these instructions instead: 1. If this is your first time setting up the environment, run `DOCKER_ENV=localwpdev ./bin/setup-local-env.sh` instead of `./bin/setup-local-env.sh` -2. If you've already had the previous environment set up, you need to start fresh, and you can do that by first running `docker-compose down --rmi all`. After that you can repeat step 1. +2. If you've already had the previous environment set up, you need to start fresh, and you can do that by first running `docker-compose down --rmi all`. After that, you can repeat step 1. 3. If you turn off your computer or restart Docker, you can get your local WordPress dev environment back by typing `docker-compose -f docker-compose.yml -f docker-compose-localdev.yml up`. If you just run `docker-compose up`, you will get the vanilla install that doesn't expose the WordPress folder. -**If everything was successful**, you'll see the following ascii art: +**If everything was successful**, you'll see the following ASCII art: ``` Welcome to... @@ -59,8 +61,8 @@ Welcome to... `---' ``` -The WordPress installation should be available at `http://localhost:8888` (username: `admin`, password: `password`). -Inside the "docker" directory, you can use any docker command to interact with your containers. If this port is in use, you can override it in your `docker-compose.override.yml` file. If you're running [e2e tests](https://wordpress.org/gutenberg/handbook/reference/testing-overview/#end-to-end-testing), this change will be used correctly. +The WordPress installation should be available at `http://localhost:8888` (**Username**: `admin`, **Password**: `password`). +Inside the "docker" directory, you can use any docker command to interact with your containers. If this port is in use, you can override it in your `docker-compose.override.yml` file. 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: ``` @@ -79,7 +81,7 @@ Next, open a terminal (or if on Windows, a command prompt) and navigate to the r ### 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 webserver and activate the plugin from the WordPress admin. +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. @@ -88,9 +90,9 @@ You can also type `npm run package-plugin` which will run the two commands above A good workflow for new contributors to follow is listed below: - Fork Gutenberg repository - Clone forked repository -- Create new branch +- Create a new branch - Make code changes -- Commit code changes within newly created branch +- Commit code changes within the newly created branch - Push branch to forked repository - Submit Pull Request to Gutenberg repository @@ -104,155 +106,35 @@ For example, `add/gallery-block` means you're working on adding a new gallery bl 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. +The workflow is documented in greater detail in the [repository management](/docs/contributors/repository-management.md) document. ## Testing -Gutenberg contains both PHP and JavaScript code, and encourages testing and code style linting for both. It also incorporates end-to-end testing using [Google Puppeteer](https://developers.google.com/web/tools/puppeteer/). You can find out more details in [Testing Overview document](./docs/contributors/testing-overview.md). - -## Managing packages - -This repository uses [lerna] to manage Gutenberg modules and publish them as packages to [npm]. - -### Creating 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.0.0" - }, - "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 (`

Code is Poetry.

`) - -### Maintaining changelogs - -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. All packages should follow the [Semantic Versioning (`semver`) specification](https://semver.org/). - -The developer who proposes a change (pull request) is responsible to choose the correct version increment (`major`, `minor`, or `patch`) according to the following guidelines: - -- Major version X (X.y.z | X > 0) should be changed with any backwards-incompatible/"breaking" change. This will usually occur at the final stage of deprecating and removing of a feature. -- Minor version Y (x.Y.z | x > 0) should be changed when you add functionality or change functionality in a backwards-compatible manner. It must be incremented if any public API functionality is marked as deprecated. -- Patch version Z (x.y.Z | x > 0) should be incremented when you make backwards-compatible bug fixes. - -When in doubt, refer to [Semantic Versioning specification](https://semver.org/). - -_Example:_ - -```md -## v1.2.2 (Unreleased) - -### Bug Fix - -- ... -- ... -``` - -- If you need to add something considered a bug fix, you add the item to `Bug Fix` section and leave the version as 1.2.2. -- If it's a new feature you add the item to `New Feature` section and change version to 1.3.0. -- If it's a breaking change you want to introduce, add the item to `Breaking Change` section and bump the version to 2.0.0. -- If you struggle to classify a change as one of the above, then it might be not necessary to include it. - -The version bump is only necessary if one of the following applies: - - There are no other unreleased changes. - - The type of change you're introducing is incompatible (more severe) than the other unreleased changes. - -### 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" - } -} -``` +Gutenberg contains both PHP and JavaScript code and encourages testing and code style linting for both. It also incorporates end-to-end testing using [Google Puppeteer](https://developers.google.com/web/tools/puppeteer/). You can find out more details in [Testing Overview document](/docs/contributors/testing-overview.md). -You can check your package configs by running `npm run lint-pkg-json`. +## Managing Packages -#### Development release - -Run the following command to release a dev version of the outdated packages, replacing `123456` with your 2FA code. Make sure you're using a freshly generated 2FA code, rather than one that's about to timeout. This is a little cumbersome, but helps to prevent the release process from dying mid-deploy. - -```bash -NPM_CONFIG_OTP=123456 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, replacing `123456` with your (freshly generated, as above) 2FA code: - -```bash -NPM_CONFIG_OTP=123456 npm run publish:prod -``` +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. -Choose the correct version based on `CHANGELOG.md` files, confirm your choices and let Lerna do its magic. +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. -## How Designers Can Contribute +## 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. If you use Sketch, you can grab the source file for the mockups (updated April 6th). ## Contribute to the Documentation -Documentation is automatically synced from master to the [Gutenberg Documentation Website](https://wordpress.org/gutenberg/handbook/) every 15 minutes. +Please see the [Documentation section](/docs/contributors/document.md) of the Contributor Handbook. -To add a new documentation page, you'll have to create a Markdown file in the [docs](https://github.com/WordPress/gutenberg/tree/master/docs) folder and add an item to the [root-manifest.json](https://github.com/WordPress/gutenberg/blob/master/docs/root-manifest.json). +Documentation is automatically synced from `master` to the [Gutenberg Handbook](https://wordpress.org/gutenberg/handbook/) 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). +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). +Please see [SECURITY.md](/SECURITY.md). ## Localizing Gutenberg Plugin diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 8279df075b66bb..e80c404136ed90 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -124,3 +124,7 @@ This list is manually curated to include valuable contributions by volunteers th | @designsimply | @designsimply | | @aldavigdis | @aldavigdis | | @miya0001 | @miyauchi | +| @naogify | @naoki0h | +| @gutendev | @gutendev | +| @drdogbot7 | @drdogbot7 | +| @m-e-h | @m-e-h | diff --git a/LICENSE.md b/LICENSE.md index 1ebca723d015de..7918e383b331d5 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ ### WordPress - Web publishing software - Copyright 2011-2018 by the contributors + 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 diff --git a/README.md b/README.md index 03251ceba2160e..861c13ff675a00 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Gutenberg -[![Build Status](https://img.shields.io/travis/WordPress/gutenberg/master.svg)](https://travis-ci.org/WordPress/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) @@ -7,7 +7,7 @@ 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. +- **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. @@ -40,11 +40,11 @@ Blocks are the unifying evolution of what is now covered, in different ways, by 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. +Check out the FAQ for answers to the most common questions about the project. ## Compatibility -Posts are backwards 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. +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 @@ -71,7 +71,7 @@ Please see 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 +- Design Principles and block design best practices - WP Post Grammar Parser - Development updates on make.wordpress.org - Documentation: Creating Blocks, Reference, and Guidelines diff --git a/assets/stylesheets/_animations.scss b/assets/stylesheets/_animations.scss index 755161bc852ff5..f856c0bf812d60 100644 --- a/assets/stylesheets/_animations.scss +++ b/assets/stylesheets/_animations.scss @@ -5,4 +5,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; } diff --git a/assets/stylesheets/_mixins.scss b/assets/stylesheets/_mixins.scss index 3cbfe6b3a04414..50bd59d38c3551 100644 --- a/assets/stylesheets/_mixins.scss +++ b/assets/stylesheets/_mixins.scss @@ -195,8 +195,8 @@ @mixin square-style__focus() { color: $dark-gray-900; - outline: 1px solid $dark-gray-300; - box-shadow: none; + outline-offset: -1px; + outline: 1px dotted $dark-gray-500; } // Menu items. @@ -209,6 +209,7 @@ color: $dark-gray-900; border: none; box-shadow: none; + background: $light-gray-200; } @mixin menu-style__focus() { @@ -226,7 +227,7 @@ } @mixin block-style__hover { - background: $light-gray-100; + background: $light-gray-200; color: $dark-gray-900; } @@ -252,7 +253,7 @@ } } - .auto-fold #{$selector} { /* Auto fold is when on smaller breakpoints, nav menu auto colllapses. */ + .auto-fold #{$selector} { /* Auto fold is when on smaller breakpoints, nav menu auto collapses. */ @include break-medium() { left: $admin-sidebar-width-collapsed; } @@ -278,7 +279,7 @@ } } - /* In small screens with resposive menu expanded there is small white space. */ + /* 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; @@ -332,3 +333,13 @@ // icon standards. margin-right: 2px; } + +/** + * Allows users to opt-out of animations via OS-level preferences. + */ + +@mixin reduce-motion { + @media (prefers-reduced-motion: reduce) { + animation-duration: 1ms !important; + } +} diff --git a/assets/stylesheets/_variables.scss b/assets/stylesheets/_variables.scss index 7fda170813addd..1c30d4f053d66a 100644 --- a/assets/stylesheets/_variables.scss +++ b/assets/stylesheets/_variables.scss @@ -52,6 +52,7 @@ $resize-handler-size: 16px; $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. diff --git a/assets/stylesheets/_z-index.scss b/assets/stylesheets/_z-index.scss index 85b0099665715d..86ab07f3e28ab1 100644 --- a/assets/stylesheets/_z-index.scss +++ b/assets/stylesheets/_z-index.scss @@ -3,61 +3,63 @@ // value is designed to work with). $z-layers: ( - ".editor-block-list__block-edit::before": 0, - ".editor-block-switcher__arrow": 1, - ".editor-block-list__block {core/image aligned left or right}": 20, - ".editor-block-list__block {core/image aligned wide or fullwide}": 20, + ".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, - ".editor-block-list__layout .reusable-block-indicator": 1, - ".editor-block-list__breadcrumb": 2, + ".block-editor-block-list__layout .reusable-block-indicator": 1, + ".block-editor-block-list__breadcrumb": 2, + ".editor-inner-blocks .block-editor-block-list__breadcrumb": 22, ".components-form-toggle__input": 1, - ".components-panel__header.edit-post-sidebar__panel-tabs": 1, - ".editor-inserter__tabs": 1, - ".editor-inserter__tab.is-active": 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, - ".editor-block-contextual-toolbar": 21, + ".block-editor-block-contextual-toolbar": 21, ".components-popover__close": 5, - ".editor-block-list__insertion-point": 6, - ".editor-inserter-with-shortcuts": 5, - ".editor-warning": 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, - ".editor-url-input__suggestions": 30, + ".block-editor-url-input__suggestions": 30, ".edit-post-header": 30, - ".block-library-button__inline-link .editor-url-input__suggestions": 6, // URL suggestions for button block above sibling inserter + ".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. // Side UI active buttons - ".editor-block-mover__control": 1, + ".block-editor-block-mover__control": 1, // Active pill button ".components-button.is-button {:focus or .is-primary}": 1, - // Reusable blocks UI, needs to be above sibling inserter. - ".editor-block-list__layout .reusable-block-edit-panel": 7, - // 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, - ".editor-block-list__block-edit .reusable-block-edit-panel *": 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": 100, ".components-drop-zone__content": 110, - // Block controls, particularly in nested contexts, floats aside block and + // The block mover, particularly in nested contexts, // should overlap most block content. - ".editor-block-list__block.is-{selected,hovered} .editor-block-{settings-menu,mover}": 80, + ".block-editor-block-list__block.is-{selected,hovered} .block-editor-block-mover": 80, + + // The block mover for floats should overlap the controls of adjacent blocks. + ".block-editor-block-list__block {core/image aligned left or right}": 81, // Small screen inner blocks overlay must be displayed above drop zone, // settings menu, and movers. - ".editor-inner-blocks__small-screen-overlay:after": 120, + ".block-editor-inner-blocks__small-screen-overlay:after": 120, // Show sidebar above wp-admin navigation bar for mobile viewports: // #wpadminbar { z-index: 99999 } @@ -68,9 +70,9 @@ $z-layers: ( // but bellow #adminmenuback { z-index: 100 } ".edit-post-sidebar {greater than small}": 90, - // Show notices below expanded wp-admin submenus: - // #adminmenuwrap { z-index: 9990 } - ".components-notice-list": 9989, + // Show notices below expanded editor bar + // .edit-post-header { z-index: 30 } + ".components-notice-list": 29, // Show modal under the wp-admin menus and the popover ".components-modal__screen-overlay": 100000, @@ -85,6 +87,7 @@ $z-layers: ( // Shows above edit post sidebar; Specificity needs to be higher than 3 classes. ".block-editor__container .components-popover.components-color-palette__picker.is-bottom": 100001, + ".block-editor__container .components-popover.components-font-size-picker__dropdown-content.is-bottom": 100001, ".edit-post-post-visibility__dialog.components-popover.is-bottom": 100001, ".components-autocomplete__results": 1000000, diff --git a/babel.config.js b/babel.config.js index 6a903eff6c1d94..b56ad5b149478b 100644 --- a/babel.config.js +++ b/babel.config.js @@ -3,27 +3,5 @@ module.exports = function( api ) { return { presets: [ '@wordpress/babel-preset-default' ], - plugins: [ - [ - '@wordpress/babel-plugin-import-jsx-pragma', - { - scopeVariable: 'createElement', - source: '@wordpress/element', - isDefault: false, - }, - ], - ], - env: { - production: { - plugins: [ - [ - '@wordpress/babel-plugin-makepot', - { - output: 'languages/gutenberg.pot', - }, - ], - ], - }, - }, }; }; diff --git a/bin/build-plugin-zip.sh b/bin/build-plugin-zip.sh index a95f2ef74b23d5..6c6d9d10512eee 100755 --- a/bin/build-plugin-zip.sh +++ b/bin/build-plugin-zip.sh @@ -47,18 +47,21 @@ 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. ✨" -git clean -xdf --dry-run -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 +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 # Download all vendor scripts @@ -97,8 +100,6 @@ status "Installing dependencies... 📦" npm install status "Generating build... 👷‍♀️" npm run build -status "Generating PHP file for wordpress.org to parse translations... 👷‍♂️" -npx pot-to-php ./languages/gutenberg.pot ./languages/gutenberg-translations.php gutenberg # Temporarily modify `gutenberg.php` with production constants defined. Use a # temp file because `bin/generate-gutenberg-php.php` reads from `gutenberg.php` @@ -118,8 +119,6 @@ zip -r gutenberg.zip \ post-content.php \ $vendor_scripts \ $build_files \ - languages/gutenberg.pot \ - languages/gutenberg-translations.php \ README.md # Reset `gutenberg.php`. diff --git a/bin/create-php-parser.js b/bin/create-php-parser.js deleted file mode 100755 index 0d661ff0f906bd..00000000000000 --- a/bin/create-php-parser.js +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env node - -const pegjs = require( 'pegjs' ); -const phpegjs = require( 'phpegjs' ); -const fs = require( 'fs' ); -const path = require( 'path' ); - -const peg = fs.readFileSync( 'packages/block-serialization-spec-parser/grammar.pegjs', 'utf8' ); - -const parser = pegjs.generate( - peg, - { - plugins: [ phpegjs ], - phpegjs: { - parserNamespace: null, - parserGlobalNamePrefix: 'Gutenberg_PEG_', - mbstringAllowed: false, - }, - } -); - -fs.writeFileSync( - path.join( __dirname, '..', 'lib', 'parser.php' ), - parser -); diff --git a/bin/generate-public-grammar.js b/bin/generate-public-grammar.js index a049d1674f36db..c56ec4398a8941 100755 --- a/bin/generate-public-grammar.js +++ b/bin/generate-public-grammar.js @@ -93,7 +93,7 @@ function flatten( expression ) { fs.writeFileSync( path.join( __dirname, '..', 'docs', 'grammar.md' ), ` -# The Gutenberg block grammar +# Block Grammar ${ flatten( grammar ) } ` ); diff --git a/bin/get-server-blocks.php b/bin/get-server-blocks.php index 851dbccfab6e6d..164fafd467db95 100755 --- a/bin/get-server-blocks.php +++ b/bin/get-server-blocks.php @@ -24,10 +24,10 @@ require_once ABSPATH . WPINC . '/functions.php'; wp_load_translations_early(); wp_set_lang_dir(); -require_once dirname( dirname( __FILE__ ) ) . '/lib/blocks.php'; -require_once dirname( dirname( __FILE__ ) ) . '/lib/class-wp-block-type-registry.php'; -require_once dirname( dirname( __FILE__ ) ) . '/lib/class-wp-block-type.php'; -require_once dirname( dirname( __FILE__ ) ) . '/lib/client-assets.php'; +require_once ABSPATH . WPINC . '/blocks.php'; +require_once ABSPATH . WPINC . '/class-wp-block-type-registry.php'; +require_once ABSPATH . WPINC . '/class-wp-block-type.php'; +require_once ABSPATH . '/wp-admin/includes/post.php'; // Register server-side code for individual blocks. foreach ( glob( dirname( dirname( __FILE__ ) ) . '/packages/block-library/src/*/index.php' ) as $block_logic ) { @@ -36,4 +36,4 @@ do_action( 'init' ); -echo json_encode( gutenberg_prepare_blocks_for_js() ); +echo json_encode( get_block_editor_server_block_settings() ); diff --git a/bin/install-wordpress.sh b/bin/install-wordpress.sh index a346152a579bda..92cee6f514e90c 100755 --- a/bin/install-wordpress.sh +++ b/bin/install-wordpress.sh @@ -45,41 +45,62 @@ echo '' # dirty up the tests. if [ "$1" == '--e2e_tests' ]; then echo -e $(status_message "Resetting test database...") - docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm $CLI db reset --yes >/dev/null + docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm -u 33 $CLI db reset --yes --quiet fi # Install WordPress. echo -e $(status_message "Installing WordPress...") # The `-u 33` flag tells Docker to run the command as a particular user and # prevents permissions errors. See: https://github.com/WordPress/gutenberg/pull/8427#issuecomment-410232369 -docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm -u 33 $CLI core install --title="$SITE_TITLE" --admin_user=admin --admin_password=password --admin_email=test@test.com --skip-email --url=http://localhost:$HOST_PORT >/dev/null +docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm -u 33 $CLI core install --title="$SITE_TITLE" --admin_user=admin --admin_password=password --admin_email=test@test.com --skip-email --url=http://localhost:$HOST_PORT --quiet if [ "$E2E_ROLE" = "author" ]; then - # Create an additional author user for testsing. - docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm -u 33 $CLI user create author author@example.com --role=author --user_pass=authpass + echo -e $(status_message "Creating an additional author user for testing...") + # Create an additional author user for testing. + docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm -u 33 $CLI user create author author@example.com --role=author --user_pass=authpass --quiet # Assign the existing Hello World post to the author. - docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm -u 33 $CLI post update 1 --post_author=2 + docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm -u 33 $CLI post update 1 --post_author=2 --quiet fi +# Make sure the uploads and upgrade folders exist and we have permissions to add files. +echo -e $(status_message "Ensuring that files can be uploaded...") +docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm $CONTAINER chmod 767 /var/www/html/wp-content/plugins +docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm $CONTAINER chmod 767 /var/www/html/wp-settings.php +docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm $CONTAINER mkdir -p /var/www/html/wp-content/uploads +docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm $CONTAINER chmod -v 767 /var/www/html/wp-content/uploads +docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm $CONTAINER mkdir -p /var/www/html/wp-content/upgrade +docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm $CONTAINER chmod 767 /var/www/html/wp-content/upgrade + +CURRENT_WP_VERSION=$(docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run -T --rm $CLI core version) +echo -e $(status_message "Current WordPress version: $CURRENT_WP_VERSION...") + if [ "$WP_VERSION" == "latest" ]; then # Check for WordPress updates, to make sure we're running the very latest version. - docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm -u 33 $CLI core update >/dev/null + echo -e $(status_message "Updating WordPress to the latest version...") + docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm -u 33 $CLI core update --quiet + echo -e $(status_message "Updating The WordPress Database...") + docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm -u 33 $CLI core update-db --quiet fi # If the 'wordpress' volume wasn't during the down/up earlier, but the post port has changed, we need to update it. +echo -e $(status_message "Checking the site's url...") CURRENT_URL=$(docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run -T --rm $CLI option get siteurl) if [ "$CURRENT_URL" != "http://localhost:$HOST_PORT" ]; then - docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm $CLI option update home "http://localhost:$HOST_PORT" >/dev/null - docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm $CLI option update siteurl "http://localhost:$HOST_PORT" >/dev/null + docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm -u 33 $CLI option update home "http://localhost:$HOST_PORT" --quiet + docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm -u 33 $CLI option update siteurl "http://localhost:$HOST_PORT" --quiet fi # Activate Gutenberg. echo -e $(status_message "Activating Gutenberg...") -docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm $CLI plugin activate gutenberg >/dev/null +docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm -u 33 $CLI plugin activate gutenberg --quiet -# Make sure the uploads folder exist and we have permissions to add files there. -docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm $CONTAINER mkdir -p /var/www/html/wp-content/uploads -docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm $CONTAINER chmod -v 767 /var/www/html/wp-content/uploads +if [ "$POPULAR_PLUGINS" == "true" ]; then + echo -e $(status_message "Activating popular plugins...") + docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm -u 33 $CLI plugin install advanced-custom-fields --activate --quiet + docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm -u 33 $CLI plugin install jetpack --activate --quiet + docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm -u 33 $CLI plugin install wpforms-lite --activate --quiet +fi # Install a dummy favicon to avoid 404 errors. +echo -e $(status_message "Installing a dummy favicon...") docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm $CONTAINER touch /var/www/html/favicon.ico diff --git a/bin/packages/build.js b/bin/packages/build.js index 02009c1e4a4d3d..bb6954b4102e7c 100755 --- a/bin/packages/build.js +++ b/bin/packages/build.js @@ -164,9 +164,7 @@ function buildScssFile( styleFile ) { function buildJsFileFor( file, silent, environment ) { const buildDir = BUILD_DIR[ environment ]; const destPath = getBuildPath( file, buildDir ); - const babelOptions = getBabelConfig( environment ); - babelOptions.sourceMaps = true; - babelOptions.sourceFileName = file; + const babelOptions = getBabelConfig( environment, file.replace( PACKAGES_DIR, '@wordpress' ) ); mkdirp.sync( path.dirname( destPath ) ); const transformed = babel.transformFileSync( file, babelOptions ); diff --git a/bin/packages/get-babel-config.js b/bin/packages/get-babel-config.js index e79bc306d07c4c..d76e171d46b214 100644 --- a/bin/packages/get-babel-config.js +++ b/bin/packages/get-babel-config.js @@ -1,71 +1,38 @@ -/** - * External dependencies - */ -const { get, map } = require( 'lodash' ); -const babel = require( '@babel/core' ); - -/** - * WordPress dependencies - */ -const { options: babelDefaultConfig } = babel.loadPartialConfig( { - configFile: '@wordpress/babel-preset-default', -} ); -const plugins = babelDefaultConfig.plugins; -if ( ! process.env.SKIP_JSX_PRAGMA_TRANSFORM ) { - plugins.push( [ '@wordpress/babel-plugin-import-jsx-pragma', { - scopeVariable: 'createElement', - source: '@wordpress/element', - isDefault: false, - } ] ); -} - -const overrideOptions = ( target, targetName, options ) => { - if ( get( target, [ 'file', 'request' ] ) === targetName ) { - return [ targetName, Object.assign( - {}, - target.options, - options - ) ]; +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; } - return target; -}; -const babelConfigs = { - main: Object.assign( - {}, - babelDefaultConfig, - { - plugins, - presets: map( - babelDefaultConfig.presets, - ( preset ) => overrideOptions( preset, '@babel/preset-env', { - modules: 'commonjs', - } ) - ), - } - ), - module: Object.assign( - {}, - babelDefaultConfig, - { - plugins: map( - plugins, - ( plugin ) => overrideOptions( plugin, '@babel/plugin-transform-runtime', { - useESModules: true, - } ) - ), - presets: map( - babelDefaultConfig.presets, - ( preset ) => overrideOptions( preset, '@babel/preset-env', { - modules: false, - } ) - ), - } - ), -}; - -function getBabelConfig( environment ) { - return babelConfigs[ environment ]; -} + // Sourcemaps options + const sourceMapsOpts = { + sourceMaps: true, + sourceFileName: file, + }; -module.exports = getBabelConfig; + return { + ...callerOpts, + ...sourceMapsOpts, + }; +}; diff --git a/bin/packages/get-packages.js b/bin/packages/get-packages.js index 30093a22abba60..ed271db0434f23 100644 --- a/bin/packages/get-packages.js +++ b/bin/packages/get-packages.js @@ -3,7 +3,7 @@ */ const fs = require( 'fs' ); const path = require( 'path' ); -const { overEvery, compact, includes, negate } = require( 'lodash' ); +const { overEvery } = require( 'lodash' ); /** * Absolute path to packages directory. @@ -12,36 +12,6 @@ const { overEvery, compact, includes, negate } = require( 'lodash' ); */ const PACKAGES_DIR = path.resolve( __dirname, '../../packages' ); -const { - /** - * Comma-separated string of packages to include in build. - * - * @type {string} - */ - INCLUDE_PACKAGES, - - /** - * Comma-separated string of packages to exclude from build. - * - * @type {string} - */ - EXCLUDE_PACKAGES, -} = process.env; - -/** - * Given a comma-separated string, returns a filter function which returns true - * if the item is contained within as a comma-separated entry. - * - * @param {Function} filterFn Filter function to call with item to test. - * @param {string} list Comma-separated list of items. - * - * @return {Function} Filter function. - */ -const createCommaSeparatedFilter = ( filterFn, list ) => { - const listItems = list.split( ',' ); - return ( item ) => filterFn( listItems, item ); -}; - /** * Returns true if the given base file name for a file within the packages * directory is itself a directory. @@ -62,11 +32,7 @@ function isDirectory( file ) { * * @return {boolean} Whether to include file in build. */ -const filterPackages = overEvery( compact( [ - isDirectory, - INCLUDE_PACKAGES && createCommaSeparatedFilter( includes, INCLUDE_PACKAGES ), - EXCLUDE_PACKAGES && createCommaSeparatedFilter( negate( includes ), EXCLUDE_PACKAGES ), -] ) ); +const filterPackages = overEvery( isDirectory ); /** * Returns the absolute path of all WordPress packages diff --git a/bin/packages/watch.js b/bin/packages/watch.js index ebf112df00c898..fce8a6beedb977 100644 --- a/bin/packages/watch.js +++ b/bin/packages/watch.js @@ -2,6 +2,7 @@ * External dependencies */ const fs = require( 'fs' ); +const watch = require( 'node-watch' ); const { execSync } = require( 'child_process' ); const path = require( 'path' ); const chalk = require( 'chalk' ); @@ -33,14 +34,13 @@ getPackages().forEach( ( p ) => { const srcDir = path.resolve( p, 'src' ); try { fs.accessSync( srcDir, fs.F_OK ); - fs.watch( path.resolve( p, 'src' ), { recursive: true }, ( event, filename ) => { - const filePath = path.resolve( srcDir, filename ); - + watch( path.resolve( p, 'src' ), { recursive: true }, ( event, filename ) => { if ( ! isSourceFile( filename ) ) { return; } - if ( ( event === 'change' || event === 'rename' ) && exists( filePath ) ) { + 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 ); diff --git a/bin/run-wp-unit-tests.sh b/bin/run-wp-unit-tests.sh index 8c426abd233a1c..8e1ee81db79e3d 100755 --- a/bin/run-wp-unit-tests.sh +++ b/bin/run-wp-unit-tests.sh @@ -21,16 +21,6 @@ fi npm run build || exit 1 -# Make sure phpegjs parser is up to date -node bin/create-php-parser.js || exit 1 -if ! git diff --quiet --exit-code lib/parser.php; then - echo 'ERROR: The PEG parser has been updated, but the generated PHP version' - echo ' (lib/parser.php) has not. Run `bin/create-php-parser.js` and' - echo ' commit the resulting changes to resolve this.' - sleep .2 # Otherwise Travis doesn't want to print the whole message - exit 1 -fi - echo Running with the following versions: if [[ $DOCKER = "true" ]]; then docker-compose $DOCKER_COMPOSE_FILE_OPTIONS run --rm wordpress_phpunit php -v @@ -40,9 +30,6 @@ else phpunit --version fi -# Check parser syntax -php lib/parser.php || exit 1 - # Run PHPUnit tests if [[ $DOCKER = "true" ]]; then npm run test-php || exit 1 diff --git a/bin/update-readmes.js b/bin/update-readmes.js new file mode 100755 index 00000000000000..b649ef5f996033 --- /dev/null +++ b/bin/update-readmes.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +const path = require( 'path' ); +const { promisify } = require( 'util' ); +const spawn = promisify( require( 'child_process' ).spawn ); + +const packages = [ + 'a11y', + 'autop', + 'blob', + 'block-editor', + 'block-library', + 'block-serialization-default-parser', + 'blocks', + 'compose', + //'data', + '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', +]; + +const getArgsForPackage = ( packageName ) => { + switch ( packageName ) { + case 'rich-text': + return [ + `packages/${ packageName }/src/index.js`, + `--output packages/${ packageName }/README.md`, + '--to-token', + '--ignore "unstable|experimental|^apply$|^changeListType$|^charAt$|^getSelectionStart$|^getSelectionEnd$|^indentListItems$|^insertLineBreak$|^insertLineSeparator$|^isEmptyLine$|^LINE_SEPARATOR$|^outdentListItems$"', + ]; + default: + return [ + `packages/${ packageName }/src/index.js`, + `--output packages/${ packageName }/README.md`, + '--to-token', + '--ignore "unstable|experimental"', + ]; + } +}; + +Promise.all( packages.map( async ( packageName ) => { + const args = getArgsForPackage( packageName ); + const pathToDocGen = path.join( __dirname, '..', 'node_modules', '.bin', 'docgen' ); + const { status, stderr } = await spawn( + pathToDocGen, + args, + { shell: true }, + ); + if ( status !== 0 ) { + throw stderr.toString(); + } +} ) ).catch( ( error ) => { + process.stderr.write( `${ error }\n` ); + process.exit( 1 ); +} ); diff --git a/docker-compose.yml b/docker-compose.yml index 797b3f7f0a8e63..9a42742d389df5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,8 @@ services: volumes: - wordpress:/var/www/html - .:/var/www/html/wp-content/plugins/gutenberg - - ./test/e2e/test-plugins:/var/www/html/wp-content/plugins/gutenberg-test-plugins - - ./test/e2e/test-mu-plugins:/var/www/html/wp-content/mu-plugins + - ./packages/e2e-tests/plugins:/var/www/html/wp-content/plugins/gutenberg-test-plugins + - ./packages/e2e-tests/mu-plugins:/var/www/html/wp-content/mu-plugins depends_on: - mysql @@ -59,8 +59,8 @@ services: volumes: - wordpress_e2e_tests:/var/www/html - .:/var/www/html/wp-content/plugins/gutenberg - - ./test/e2e/test-plugins:/var/www/html/wp-content/plugins/gutenberg-test-plugins - - ./test/e2e/test-mu-plugins:/var/www/html/wp-content/mu-plugins + - ./packages/e2e-tests/plugins:/var/www/html/wp-content/plugins/gutenberg-test-plugins + - ./packages/e2e-tests/mu-plugins:/var/www/html/wp-content/mu-plugins cli_e2e_tests: image: wordpress:cli diff --git a/docs/contributors/coding-guidelines.md b/docs/contributors/coding-guidelines.md index 8264e765231048..5230d561fa98a5 100644 --- a/docs/contributors/coding-guidelines.md +++ b/docs/contributors/coding-guidelines.md @@ -57,7 +57,7 @@ Example: /** * External dependencies */ -import TinyMCE from 'tinymce'; +import moment from 'moment'; ``` #### WordPress Dependencies @@ -102,76 +102,30 @@ If an API must be exposed but is clearly not intended to be supported into the f export { __unstableDoAction } from './api'; ``` -### Variable Naming +### Objects -Gutenberg inherits [WordPress' naming conventions of camel-casing](https://make.wordpress.org/core/handbook/best-practices/coding-standards/javascript/#naming-conventions): - ->Variable and function names should be full words, using camel case with a lowercase first letter. This is an area where this standard differs from the WordPress PHP coding standards. -> ->Constructors intended for use with `new` should have a capital first letter (UpperCamelCase). - -However, Gutenberg is more specific about its handling of abbreviations, acronyms, constants, and the ES2015 class construct. - -#### Abbreviations and Acronyms - -[*Abbreviations*](https://en.wikipedia.org/wiki/Abbreviation) must be written as camel case, with an initial capitalized letter followed by lowercase letters. - -[*Acronyms*](https://en.wikipedia.org/wiki/Acronym) must be written with each of its composing letters capitalized. This is intended to reflect that each letter of the acronym is a proper word in its expanded form. - -If an abbreviation or an acronym occurs at the start of a variable name, it must be written to respect the camelcase naming rules covering the first letter of a variable or class definition. For variable assignment, this means writing the abbreviation entirely as lowercase. For class definitions, its initial letter should be capitalized. - -**Examples:** - -```js -// "Id" is an abbreviation of "Identifier": -const userId = 1; - -// "DOM" is an acronym of "Document Object Model": -const currentDOMDocument = window.document; - -// Acronyms and abbreviations at the start of a variable name are consistent -// with camelcase rules covering the first letter of a variable or class. -const domDocument = window.document; -class DOMDocument {} -class IdCollection {} -``` - -#### Class Definition - -A [`class` definition](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes) must use the UpperCamelCase convention, regardless of whether it is intended to be used with `new` construction. - -**Example:** +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 -class Earth { - static addHuman( human ) { - Earth.humans.push( human ); - } - - static getHumans() { - return Earth.humans; - } -} - -Earth.humans = []; -``` - -All `@wordpress/element` Components, including stateless function components, should be named using Class Definition naming rules, both for consistency and to reflect the fact that a component may need to be transitioned from a function to a class without breaking compatibility. - -**Examples:** +const a = 10; -```js -class MyComponent extends Component {} +// Bad: +const object = { + a: a, + performAction: function() { + // ... + }, +}; -function MyComponent() {} +// Good: +const object = { + a, + performAction() { + // ... + }, +}; ``` -#### Constants - -An exception to camel case is made for constant values which are never intended to be reassigned or mutated. Such variables must use the [SCREAMING_SNAKE_CASE convention](https://en.wikipedia.org/wiki/Snake_case). - -In almost all cases, a constant should be defined in the top-most scope of a file. It is important to note that [JavaScript's `const` assignment](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const) is conceptually more limited than what is implied here, where a value assigned by `const` in JavaScript can in-fact be mutated, and is only protected against reassignment. A constant as defined in these coding guidelines applies only to values which are expected to never change, and is a strategy for developers to communicate intent moreso than it is a technical restriction. - ### 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. @@ -188,7 +142,7 @@ const name = 'Matt'; // Bad: const pet = 'Matt\'s dog'; -// Also bad (not using an apostrophe): +// Also bad (not using an apostrophe): const pet = "Matt's dog"; // Good: const pet = 'Matt’s dog'; diff --git a/docs/contributors/copy-guide.md b/docs/contributors/copy-guide.md index 5e0066a689604c..a3178b69109b3c 100644 --- a/docs/contributors/copy-guide.md +++ b/docs/contributors/copy-guide.md @@ -1,4 +1,4 @@ -# Gutencopy Guidelines +# Copy Guidelines ## Longer Text Guidelines for writing multi-line/step instructions or narrative introductions/orientation to pages or features. @@ -8,7 +8,7 @@ This will obviously vary quite a lot depending on the context, but here are some #### 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. +#### 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. @@ -25,7 +25,7 @@ Does it or doesn’t it? We’re making this software: we’re allowed to be dec > The gallery block displays multiple images in an elegant layout. -We also all do this a lot with the phrase “allows you to.” +We also all do this a lot with the phrase “allows you to.” > Preformatted text allows you to keep your tabs and line breaks. @@ -33,9 +33,9 @@ Features don’t allow anyone to do anything; they’re just tools that do speci > 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. +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.” +#### 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.” @@ -43,7 +43,7 @@ It’s also safer and more helpful to be specific. “Easy” and “simple” a 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.” +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. @@ -51,7 +51,7 @@ We’re the only ones that care about what we did or want; the user just wants s 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. +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! @@ -60,7 +60,7 @@ GOOD: > * 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. +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. @@ -75,7 +75,7 @@ Here, every line has different phrasing (some start with a verb, some with a nou 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! “Predicable” doesn’t have to mean “simple.” It just means that each bullet should have the same sentence structure. This list would also be fine: +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! @@ -101,7 +101,7 @@ If your list is more persuasive (e.g., trying to convince someone to use a featu >* 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. +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. @@ -120,7 +120,7 @@ LESS GOOD: 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. +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: @@ -136,7 +136,7 @@ On the flipside, bolding too many things creates visual confusion: > * 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. +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**. @@ -148,7 +148,7 @@ 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! +#### 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. @@ -187,7 +187,7 @@ And when something means everything, it actually means nothing. The more specifi #### 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. +Single lines of description can still be complete sentences. > List. Numbered or bulleted. @@ -195,7 +195,7 @@ vs. > Add a list, either numbered or bulleted. -You can still use contractions. +You can still use contractions. > Add a list. We will provide formatting options. @@ -203,7 +203,7 @@ 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. +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. diff --git a/docs/contributors/design.md b/docs/contributors/design.md index aefba0d0eb3d4f..bd68ba060718ed 100644 --- a/docs/contributors/design.md +++ b/docs/contributors/design.md @@ -1,4 +1,4 @@ -# Gutenberg Design Principles & Vision +# 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. @@ -46,7 +46,7 @@ Gutenberg wants to make it easier to author rich content. This means ensuring go 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: +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 { diff --git a/docs/contributors/develop.md b/docs/contributors/develop.md new file mode 100644 index 00000000000000..97fa91aab0f78d --- /dev/null +++ b/docs/contributors/develop.md @@ -0,0 +1,11 @@ +# Developer Contributions + +Please see [CONTRIBUTING.md](https://github.com/WordPress/gutenberg/blob/master/CONTRIBUTING.md) for technical details on how to setup and make contributions to the Gutenberg repository. + +The following resources offer additional information for developers who wish to contribute to Gutenberg: + +* [Coding Guidelines](/docs/contributors/coding-guidelines.md) outline additional patterns and conventions used in the Gutenberg project. +* [Gutenberg Block Grammar](/docs/contributors/grammar.md) +* [Testing Overview](/docs/contributors/testing-overview.md) for PHP and JavaScript development in Gutenberg. +* [Scripts](/docs/contributors/scripts.md) a list of vendor and internal scripts available to plugin developers. +* [Gutenberg Release Process](/docs/contributors/release.md) a checklist for the different type of releases for Gutenberg project. diff --git a/docs/contributors/document.md b/docs/contributors/document.md new file mode 100644 index 00000000000000..f51578d99e3c31 --- /dev/null +++ b/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 [Gutenberg Handbook site](https://wordpress.org/gutenberg/handbook/). + +## 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: + +- Gutenberg 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/docs/contributors/git-workflow.md b/docs/contributors/git-workflow.md new file mode 100644 index 00000000000000..f159b46126e617 --- /dev/null +++ b/docs/contributors/git-workflow.md @@ -0,0 +1,44 @@ +# Git Workflow + +## 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/docs/contributors/grammar.md b/docs/contributors/grammar.md index ac6015b9a2dc70..7d7e9bf73b8c04 100644 --- a/docs/contributors/grammar.md +++ b/docs/contributors/grammar.md @@ -1,5 +1,5 @@ -# The Gutenberg block grammar +# 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 diff --git a/docs/contributors/outreach.md b/docs/contributors/outreach.md index 9df69e7ac58b6c..208a1eaf707e14 100644 --- a/docs/contributors/outreach.md +++ b/docs/contributors/outreach.md @@ -1,8 +1,58 @@ # Outreach -This will include talks, meetups 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 just let us know. +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 @@ -13,15 +63,3 @@ https://www.meetup.com/WordPress-Melbourne/events/241543639 https://wpmeetups.de/termin/29-wp-meetup-stuttgart-gutenberg-editor-rueckblick-wordcamp-europe/ -## Testing events: -https://www.meetup.com/Turku-WordPress-Meetup/events/241195076/ - -https://www.meetup.com/Vancouver-WordPress-Meetup-Group/events/241575161/ - -## Calls for testing: -https://make.wordpress.org/test/2017/06/27/call-for-testing-gutenberg/ - -http://www.wpswfl.org/new-wordpress-editor-gutenberg-early-beta-needs-testers/ - -https://gutenberg.eastbaywp.com - diff --git a/docs/contributors/outreach/articles.md b/docs/contributors/outreach/articles.md deleted file mode 100644 index fe4bce7e328a82..00000000000000 --- a/docs/contributors/outreach/articles.md +++ /dev/null @@ -1,30 +0,0 @@ -# Articles - -This includes useful articles for those wanting to run a meetup or promote 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/) diff --git a/docs/contributors/outreach/meetups.md b/docs/contributors/outreach/meetups.md deleted file mode 100644 index 58cd8bbb72c992..00000000000000 --- a/docs/contributors/outreach/meetups.md +++ /dev/null @@ -1,17 +0,0 @@ -# Meetups - -A list of meetups about Gutenberg so far: - -- [Gutenberg and the Future of WordPress](https://www.meetup.com/Vancouver-WordPress-Meetup-Group/events/241575161/), Vancouver, Canada -- [Page builders and the upcoming Gutenberg Editor](https://www.meetup.com/Turku-WordPress-Meetup/events/241195076/), Turku, Finland -- [Discussion about Gutenberg](https://www.facebook.com/events/278785795934302/), Andria, Italy -- [Plugins and Gutenberg](https://wpleeds.co.uk/events/plugins-gutenberg-wordpress-leeds-july-2017/), Leeds, UK -- [Gutenberg Introduction & Demo](https://www.meetup.com/WordPress-Melbourne/events/241543639/), Melbourne, Australia -- [Gutenberg Editor & Review](https://wpmeetups.de/termin/29-wp-meetup-stuttgart-gutenberg-editor-rueckblick-wordcamp-europe/), WordCamp Europe -- [Diving into Gutenberg by Tammie Lister](https://www.meetup.com/Big-Media-Enterprise-WordPress-London-Meetup/events/243302081/), London, UK -- [What's New In WordPress 4.9 and Gutenberg 1.5](https://www.meetup.com/Tuscaloosa-WordPress-Meetup/events/244584939/), Tuscaloosa, Alabama, USA -- [WordPress & JavaScript: Let's talk Gutenberg!](https://www.meetup.com/WordPress-Lahore/events/246446478/), Lahore, PK -- [The state of Gutenberg](https://www.meetup.com/WP-Porto/events/245585131/), Porto, Portugal -- [Discuss and learn about the new WordPress Editor : Gutenberg](https://www.meetup.com/Pune-WordPress-Knowledge-Exchange/events/248496830/), Pune, India -- [An Introduction to Gutenberg](https://www.meetup.com/Okanagan-WordPress-Meetup/events/249167218/), Vernon, BC, Canada -- [WordPress 5.0 - Gutenberg is upon us](https://www.meetup.com/WordPress-Perth/events/249490075/), Perth, Australia diff --git a/docs/contributors/outreach/resources.md b/docs/contributors/outreach/resources.md deleted file mode 100644 index 3c5dd598f60858..00000000000000 --- a/docs/contributors/outreach/resources.md +++ /dev/null @@ -1,11 +0,0 @@ -# Resources - -All resources here can be used by anyone. Feel free to use the decks to make your own talks or to use the gifs in your blog posts and other resources. - -## Slidedecks to use - -- v1: https://cloudup.com/cqEJppQ8m-5 : August 2017 - -## Gif collection to use - -- https://cloudup.com/c9OKU3OJD9r diff --git a/docs/contributors/outreach/talks.md b/docs/contributors/outreach/talks.md deleted file mode 100644 index a21f9c468c4814..00000000000000 --- a/docs/contributors/outreach/talks.md +++ /dev/null @@ -1,17 +0,0 @@ -# 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 diff --git a/docs/contributors/readme.md b/docs/contributors/readme.md index e69de29bb2d1d6..685af4590b4737 100644 --- a/docs/contributors/readme.md +++ b/docs/contributors/readme.md @@ -0,0 +1,18 @@ +# Contributors Guide + +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/docs/contributors/reference.md b/docs/contributors/reference.md index 2a4714d5e75a23..95c0b603323e50 100644 --- a/docs/contributors/reference.md +++ b/docs/contributors/reference.md @@ -1,9 +1,9 @@ # 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) +- [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 @@ -14,4 +14,4 @@ Released under GPL license, made by [Cristel Rossignol](https://twitter.com/cris ## Mockups -Mockup Sketch files are available in the Design section. +Mockup Sketch files are available in [the Design section](/docs/designers-developers/designers/design-resources.md). diff --git a/docs/contributors/release.md b/docs/contributors/release.md index e909ab5281aabb..d88c260a3904a9 100644 --- a/docs/contributors/release.md +++ b/docs/contributors/release.md @@ -1,170 +1,154 @@ # Gutenberg Release Process -This document serves as a checklist for building and releasing a new version of Gutenberg. It documents our release process, but it is helpful if you'd like to understand how Gutenberg ships release candidates and new versions. +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]. 🙂 +To release Gutenberg, you need commit access to the [WordPress.org plugin repository][plugin repository]. 🙂 -## Gutenberg Versioning +## Plugin Releases -Gutenberg follows semantic versioning; 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. The date in the milestone is the date of **tagging the release candidate**, not the final version that will be published to the [plugin repository]. +### Versioning -Up until version `4.0.0`, "point releases"/minor releases (eg. `3.8` > `3.9`) effectively constituted a major version bump. +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. -Our [deprecation policy](./deprecated.md) maintains deprecated APIs/functions for two major versions, whenever possible. +### Release Candidates -# Release Candidates +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. -Since [`3.8`](https://github.com/WordPress/gutenberg/releases/tag/v3.8.0), we [publish a release candidate](https://github.com/WordPress/gutenberg/releases/tag/v3.8.0-rc.1) at least one week before we publish a new version. This release candidate is available for plugin authors and for users to test. If any bugs/regressions are found with a release candidate, they should be cherry-picked into a new release candidate and it should 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. -## Creating a Release Candidate +#### Creating the first Release Candidate -Creating a release candidate involves: +Releasing the first release candidate for this milestone (`x.x`) involves: 1. writing a release blog post and changelog -2. bumping the version -3. tagging the release +2. creating the release branch +3. bumping the version and tagging the release 4. building the plugin -5. publishing the new release to GitHub -6. committing to the [plugin repository] +5. publishing the release to GitHub +6. publishing the call for testing -### Writing the Release Post and Changelog +##### 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. -### Bumping the Version +##### Creating the Release Branch -1. Create [a pull request like this](https://github.com/WordPress/gutenberg/pull/9663), bumping the version number in `gutenberg.php`, `package.json`, and `package-lock.json`. -2. Check that there's no work-in-progress that's just about to land. [Inform committers in `#core-editor` on Slack](https://make.wordpress.org/chat/) to hold off on merging any changes until after the release candidate is tagged. -3. Merge the version bump pull request. +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. -### Tag the Release +``` +git checkout master +git checkout -b release/x.x +git push origin release/x.x +``` -1. [Create a new release on GitHub](https://github.com/WordPress/gutenberg/releases/new). -2. If you were releasing the `5.0.0` release candidate, label it `v5.0.0-rc.1`. -3. The GitHub release screen should look like this: -[![GitHub Release Screenshot](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/reference/release-screenshot.png)](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/reference/release-screenshot.png) -4. Creative emojis related to a key feature in this release are encouraged. Emojis are fun! -5. Publish the release. +##### 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 +##### Build the Plugin 1. Run `git fetch --tags`. -2. Check out the tag for this release; it will be the tag you created on the GitHub release page; assuming version `5.0.0`, you should run `git checkout v5.0.0-rc.1`. +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 +##### Publish the Release on GitHub -1. Upload the a `gutenberg.zip` file to the [GitHub Releases page](https://github.com/WordPress/gutenberg/releases) -2. Post a link to the release page to the [`#core-editor` channel](https://wordpress.slack.com/messages/C02QB2JS7). - -Here's an example [release candidate page](https://github.com/WordPress/gutenberg/releases/tag/v3.8.0-rc.1); yours should look like that when you're finished. +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. -### Commit to the Plugin Repository +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. -You'll need to use Subversion for this step. +##### Publishing the Call For Testing -1. Do an SVN checkout of `https://wordpress.org/plugins/gutenberg/`: - * If this is your first checkout, run: `svn checkout https://plugins.svn.wordpress.org/gutenberg` - * If you already have a copy, run: `svn up` -2. Delete the contents of `trunk` 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 to `trunk`. -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 to `trunk`: -```bash -# Replace vX.X.X-rc.1 with your version: -svn ci -m "Committing Gutenberg version vX.X.X-rc.1" -``` +Ping someone from the `[#core-test](https://wordpress.slack.com/messages/C03B0H5J0)` team to publish a call for testing post. Here's an [example call for testing post.](https://make.wordpress.org/test/2019/01/04/call-for-testing-gutenberg-4-8/) -## Creating Release Candidate Patches (done via `git cherry-pick`) +#### 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`. This way only fixes are added to the release candidate and not all the new code that has landed on `master` since tagging: +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. Create a pull request against master that only bumps the release candidate number in `gutenberg.php`, `package.json`, and `package-lock.json`. If your current release candidate is `v3.8.0-rc.1`, you would change the version number to `v3.8.0-rc.2` -2. After this pull request is merged into master, note its commit SHA. -3. Run `git fetch --tags` to load the tags. -4. Check out the latest release candidate (for example: `git checkout v3.8.0-rc.1`). -5. Cherry-pick fix commits (in chronological order) with `git cherry-pick [SHA]`. -6. Cherry-pick the version bump commit you noted in step 2. -7. Tag this release by incrementing its `rc.X` number and push it to GitHub: -```bash -git tag v3.8.0-rc.2 -git push origin v3.8.0-rc.2 -``` -8. Follow the steps in [tag the release](#tag-the-release), [build the plugin](#build-the-plugin), [publish the release on GitHub](#publish-the-release-on-github), and [commit to the plugin repository](#commit-to-the-plugin-repository). You can copy the existing changelog from the previous release candidate. +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). -It's worth mentioning that a new release candidate has been released in the [`#core-editor` channel](https://wordpress.slack.com/messages/C02QB2JS7). +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™ +### 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. The steps below are very similar to the ones above, for a release candidate, but they're spelled out here in their entirely to reduce confusion. 😅 +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 Creating a release involves: 1. verifying the release blog post and changelog 2. bumping the version -4. building the plugin -5. publishing the new release to GitHub -6. committing to the [plugin repository] -7. publishing the release blog post +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 +##### 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 +##### 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. -1. Create [a pull request with 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`. -2. Merge the version bump pull request. (If you want to live dangerously, you can push the version bump directly to `master`. 😎) -3. Note the commit SHA of the version bump. -4. Cherry-pick the version bump commit into your release candidate branch: -```bash -# Assuming you're releasing version v5.0.0, based on v5.0.0-rc.1. -git fetch --tags -git checkout v.5.0.0-rc.1 -git cherry-pick [VERSION-BUMP-COMMIT-SHA] -git tag v5.0.0 -git push origin v5.0.0 -``` -### Build the Plugin +##### Build the Plugin 1. Run `git fetch --tags`. -2. Check out the tag for this release; it will be the tag you created on the GitHub release page; assuming version `5.0.0`, you should run `git checkout v5.0.0-rc.1`. +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 +##### Publish the Release on GitHub 1. [Create a new release on GitHub](https://github.com/WordPress/gutenberg/releases/new). -2. If you are releasing `5.0.0`, target the tag `v5.0.0`. -3. Upload the `gutenberg.zip` file -4. Post a link to the release page to the [`#core-editor` channel](https://wordpress.slack.com/messages/C02QB2JS7). - -Here's an example [release page](https://github.com/WordPress/gutenberg/releases/tag/v3.8.0); yours should look like that when you're finished. +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 +##### 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/`: - * If this is your first checkout, run: `svn checkout https://plugins.svn.wordpress.org/gutenberg` +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 of `trunk` 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 to `trunk`. +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: @@ -174,17 +158,16 @@ 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 to `trunk`: +7. Commit the new version: ```bash -# Replace vX.X.X with your version: -svn ci -m "Committing Gutenberg version vX.X.X" +# Replace X.X.X with your version: +svn ci -m "Committing Gutenberg version X.X.X" ``` -8. Tag the new version. Make sure you're in the root directory of `gutenberg`, then run: +8. Tag the new version: ```bash -svn cp trunk tags/X.X.X -svn ci -m "Tagging Gutenberg version X.X.X" +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 `trunk/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: +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" ``` @@ -198,10 +181,63 @@ You should check that folks are able to install the new version from their Dashb 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 [Matias Ventura](https://profiles.wordpress.org/matveb) or someone else on the Gutenberg Core team to publish the post. +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/docs/contributors/repository-management.md b/docs/contributors/repository-management.md index 132f46d0df1edd..7eaa1cb28661e0 100644 --- a/docs/contributors/repository-management.md +++ b/docs/contributors/repository-management.md @@ -1,6 +1,6 @@ # Repository Management -The goal is for this to be 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 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: @@ -17,15 +17,25 @@ This document covers: ## Issues -A healthy issue backlog 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. +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 backlog as a desk: the more clutter you have on it, the more difficult it is to use the space to get work done. +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 -To better organize the issue backlog, all issues should have [one or more labels](https://github.com/WordPress/gutenberg/labels). Here are some you might commonly see: +All issues should have [one or more labels](https://github.com/WordPress/gutenberg/labels). -- [Accessibility](https://github.com/WordPress/gutenberg/labels/Accessibility) - Changes that impact accessibility and need corresponding review (e.g. markup changes). +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. @@ -36,52 +46,43 @@ To better organize the issue backlog, all issues should have [one or more labels ### Milestones -We put issues into [milestones](https://github.com/wordpress/gutenberg/milestones) to better categorize them. Here are some you might see: - -- The next 2 releases we have milestones for (e.g. 2.2, 2.3). -- [Feature Complete](https://github.com/WordPress/gutenberg/milestone/8): This includes big features and is what will be managing the vision of Gutenberg. All of this would be done before even merge proposal is thought about. Examples here include nesting, drag and drop and extensibility API. -- [Merge Proposal: Editor](https://github.com/WordPress/gutenberg/milestone/22): All issues related to merge proposal for the editor. -- [Merge Proposal: Rest API](https://github.com/WordPress/gutenberg/milestone/39): All issues related to merge proposal for the Rest API -- [Merge Proposal: Accessibility](https://github.com/WordPress/gutenberg/milestone/43): All accessibility issues related to merge proposal. -- [Merge Proposal: Media](https://github.com/WordPress/gutenberg/milestone/42): All issues related to merge proposal for the media component. -- [Merge Proposal: Documentation](https://github.com/WordPress/gutenberg/milestone/50): All issues related to documentation for the merge proposal. -- [Merge Proposal: i18n](https://github.com/WordPress/gutenberg/milestone/49): All translation issues for the merge proposal. -- [Merge Proposal: Customization](https://github.com/WordPress/gutenberg/milestone/44): All Customization issues for the merge proposal. -- [Merge Proposal: Plugin](https://github.com/WordPress/gutenberg/milestone/48): All plugin and extensibility issues for the merge proposal. -- [Merge Proposal: Back Compat](https://github.com/WordPress/gutenberg/milestone/47): All back compatibility issues for the merge proposal. -- [Merge Proposal: Themes](https://github.com/WordPress/gutenberg/milestone/48): All theme issues for the merge proposal. -- [Merge Proposal: Core](https://github.com/WordPress/gutenberg/milestone/45): All core issues for the merge proposal that don't fit other merge proposal milestones. -- [Bonus Features](https://github.com/WordPress/gutenberg/milestone/32): Again likely not part of triage and includes nice to haves for the project, if time before merge. A few examples include collaborative editing and footnotes. +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 backlog 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. +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 the backlog, although you’ll need contributor permission on the Gutenberg repository to modify an issue’s labels or edit its title. +Anyone can help triage, although you’ll need contributor permission on the Gutenberg repository to modify an issue’s labels or edit its title. Here are a couple places you can start: -- [All Gutenberg issues without an assigned label](https://github.com/wordpress/gutenberg/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-asc+no%3Alabel) -- [The least recently updated Gutenberg issues](https://github.com/WordPress/gutenberg/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-asc) +- [All Gutenberg issues without an assigned label](https://github.com/wordpress/gutenberg/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-asc+no%3Alabel). +- [The least recently updated Gutenberg issues](https://github.com/WordPress/gutenberg/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-asc). -When reviewing the issue backlog, here are some steps you can perform: +When reviewing issues, here are some steps you can perform: -- If it’s a bug report, test to confirm the report. If there is not enough information to confirm the report, add the `[Status] Needs More Info` label. -- If the issue is missing labels, add some to better categorize it. -- If the issue is duplicate of another already in the backlog, close the issue by commenting with “Duplicate of #”. Add any relevant new details to the existing issue. +- 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 the title doesn’t communicate the issue, edit it for clarity. - 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 GIF. + - 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. ## 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. @@ -125,6 +126,7 @@ A pull request can generally be merged once it is: - 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. @@ -154,6 +156,6 @@ We use [GitHub projects](https://github.com/WordPress/gutenberg/projects) to kee Some key projects include: -* [Customization](https://github.com/WordPress/gutenberg/projects/13) - Blocks and tasks needed for customization in Gutenberg. -* [Extensibility](https://github.com/WordPress/gutenberg/projects/14) - Comprises the entirety of extensibility APIs. See [Native Gutenberg Extensibility Overview](https://github.com/WordPress/gutenberg/issues/3330) for more details. -* [Third-Party Compatibility](https://github.com/WordPress/gutenberg/projects/15) - Issue that impact Gutenberg's adoption in the real world. +* [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/docs/contributors/scripts.md b/docs/contributors/scripts.md index dd7e1d295d528c..4928ec91bde64b 100644 --- a/docs/contributors/scripts.md +++ b/docs/contributors/scripts.md @@ -8,38 +8,38 @@ The editor includes a number of packages to enable various pieces of functionali | Script Name | Handle | Description | |-------------|--------|-------------| -| [Blob](https://wordpress.org/gutenberg/handbook/packages/packages-blob/) | wp-blob | Blob utilities | -| [Block Library](https://wordpress.org/gutenberg/handbook/packages/packages-block-library/) | wp-block-library | Block library for the editor | -| [Blocks](https://wordpress.org/gutenberg/handbook/packages/packages-blocks/) | wp-blocks | Block creations | -| [Block Serialization Default Parser](https://wordpress.org/gutenberg/handbook/packages/packages-block-serialization-default-parser/) | wp-block-serialization-default-parser | Default block serialization parser implementations for WordPress documents | -| [Block Serialization Spec Parser](https://wordpress.org/gutenberg/handbook/packages/packages-block-serialization-spec-parser/) | wp-block-serialization-spec-parser | Grammar file (grammar.pegjs) for WordPress posts | -| [Components](https://wordpress.org/gutenberg/handbook/packages/packages-components/) | wp-components | Generic components to be used for creating common UI elements | -| [Compose](https://wordpress.org/gutenberg/handbook/packages/packages-compose/) | wp-compose | Collection of handy Higher Order Components (HOCs) | -| [Core Data](https://wordpress.org/gutenberg/handbook/packages/packages-core-data/) | wp-core-data | Simplify access to and manipulation of core WordPress entities | -| [Data](https://wordpress.org/gutenberg/handbook/packages/packages-data/) | wp-data | Data module serves as a hub to manage application state for both plugins and WordPress itself | -| [Date](https://wordpress.org/gutenberg/handbook/packages/packages-date/) | wp-date | Date module for WordPress | -| [Deprecated](https://wordpress.org/gutenberg/handbook/packages/packages-deprecated/) | wp-deprecated | Utility to log a message to notify developers about a deprecated feature | -| [Dom](https://wordpress.org/gutenberg/handbook/packages/packages-dom/) | wp-dom | DOM utilities module for WordPress | -| [Dom Ready](https://wordpress.org/gutenberg/handbook/packages/packages-dom-ready/) | wp-dom-ready | Execute callback after the DOM is loaded | -| [Editor](https://wordpress.org/gutenberg/handbook/packages/packages-editor/) | wp-editor | Building blocks for WordPress editors | -| [Edit Post](https://wordpress.org/gutenberg/handbook/packages/packages-edit-post/) | wp-edit-post | Edit Post Module for WordPress | -| [Element](https://wordpress.org/gutenberg/handbook/packages/packages-element/) | wp-element |Element is, quite simply, an abstraction layer atop [React](https://reactjs.org/) | -| [Escape Html](https://wordpress.org/gutenberg/handbook/packages/packages-escape-html/) | wp-escape-html | Escape HTML utils | -| [Hooks](https://wordpress.org/gutenberg/handbook/packages/packages-hooks/) | wp-hooks | A lightweight and efficient EventManager for JavaScript | -| [Html Entities](https://wordpress.org/gutenberg/handbook/packages/packages-html-entities/) | wp-html-entities | HTML entity utilities for WordPress | -| [I18N](https://wordpress.org/gutenberg/handbook/packages/packages-i18n/) | wp-i18n | Internationalization utilities for client-side localization | -| [Is Shallow Equal](https://wordpress.org/gutenberg/handbook/packages/packages-is-shallow-equal/) | wp-is-shallow-equal | A function for performing a shallow comparison between two objects or arrays | -| [Keycodes](https://wordpress.org/gutenberg/handbook/packages/packages-keycodes/) | wp-keycodes | Keycodes utilities for WordPress, used to check the key pressed in events like `onKeyDown` | -| [List Reusable Bocks](https://wordpress.org/gutenberg/handbook/packages/packages-list-reusable-blocks/) | wp-list-reusable-blocks | Package used to add import/export links to the listing page of the reusable blocks | -| [NUX](https://wordpress.org/gutenberg/handbook/packages/packages-nux/) | wp-nux | Components, and wp.data methods useful for onboarding a new user to the WordPress admin interface | -| [Plugins](https://wordpress.org/gutenberg/handbook/packages/packages-plugins/) | wp-plugins | Plugins module for WordPress | -| [Redux Routine](https://wordpress.org/gutenberg/handbook/packages/packages-redux-routine/) | wp-redux-routine | Redux middleware for generator coroutines | -| [Rich Text](https://wordpress.org/gutenberg/handbook/packages/packages-rich-text/) | wp-rich-text | Helper functions to convert HTML or a DOM tree into a rich text value and back | -| [Shortcode](https://wordpress.org/gutenberg/handbook/packages/packages-shortcode/) | wp-shortcode | Shortcode module for WordPress | -| [Token List](https://wordpress.org/gutenberg/handbook/packages/packages-token-list/) | wp-token-list | Constructable, plain JavaScript [DOMTokenList](https://developer.mozilla.org/en-US/docs/Web/API/DOMTokenList) implementation, supporting non-browser runtimes | -| [URL](https://wordpress.org/gutenberg/handbook/packages/packages-url/) | wp-url | A collection of utilities to manipulate URLs | -| [Viewport](https://wordpress.org/gutenberg/handbook/packages/packages-viewport/) | wp-viewport | Module for responding to changes in the browser viewport size | -| [Wordcount](https://wordpress.org/gutenberg/handbook/packages/packages-wordcount/) | wp-wordcount | WordPress word count utility | +| [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 @@ -55,7 +55,7 @@ The editor also uses some popular third-party packages and scripts. Plugin devel ## Polyfill Scripts The editor also provides polyfills for certain features that may not be available in all modern browsers. -It is recommened to use the main `wp-polyfill` script handle which takes care of loading all the below mentioned polyfills. +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 | |-------------|--------|-------------| diff --git a/docs/contributors/testing-overview.md b/docs/contributors/testing-overview.md index c54ef5a7e6033b..6992bd664c3ec2 100644 --- a/docs/contributors/testing-overview.md +++ b/docs/contributors/testing-overview.md @@ -19,7 +19,7 @@ When writing tests consider the following: ## JavaScript Testing -Tests for JavaScript use [Jest](http://facebook.github.io/jest/) as the test runner and its API for [globals](https://facebook.github.io/jest/docs/en/api.html) (`describe`, `test`, `beforeEach` and so on) [assertions](http://facebook.github.io/jest/docs/en/expect.html), [mocks](http://facebook.github.io/jest/docs/en/mock-functions.html), [spies](http://facebook.github.io/jest/docs/en/jest-object.html#jestspyonobject-methodname) and [mock functions](https://facebook.github.io/jest/docs/en/mock-function-api.html). If needed, you can also use [Enzyme](https://github.com/airbnb/enzyme) for React component 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](https://github.com/WordPress/gutenberg/blob/master/CONTRIBUTING.md) to install Node and project dependencies, tests can be run from the command-line with NPM: @@ -88,9 +88,9 @@ describe( 'CheckboxWithLabel', () => { ### Setup and Teardown methods -The Jest API includes some nifty [setup and teardown methods](https://facebook.github.io/jest/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. +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://facebook.github.io/jest/docs/en/asynchronous.html#promises), you can return a Promise and Jest will wait for it to resolve: +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 @@ -188,7 +188,7 @@ describe( 'The bilbo module', () => { ### Testing globals -We can use [Jest spies](http://facebook.github.io/jest/docs/en/jest-object.html#jestspyonobject-methodname) to test code that calls global methods. +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'; @@ -220,7 +220,7 @@ However, if the change was intentional, follow these steps to update the snapsho npm run test-unit -- --updateSnapshot --testPathPattern path/to/tests ``` 1. Review the diff and ensure the changes are expected and intentional. -1. Commit. +2. Commit. #### What are snapshots? @@ -286,7 +286,7 @@ describe( 'SolarSystem', () => { } ); ``` -Reducer tests are also be a great fit for snapshots. They are often large, complex data structures that shouldn't change unexpectedly, exactly what snapshots excel at! +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 @@ -356,6 +356,12 @@ or interactively 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 @@ -374,5 +380,5 @@ Code style in PHP is enforced using [PHP_CodeSniffer](https://github.com/squizla To run unit tests only, without the linter, use `npm run test-unit-php` instead. -[snapshot testing]: https://facebook.github.io/jest/docs/en/snapshot-testing.html -[update snapshots]: https://facebook.github.io/jest/docs/en/snapshot-testing.html#updating-snapshots +[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/docs/designers-developers/assets/fancy-quote-in-inspector.png b/docs/designers-developers/assets/fancy-quote-in-inspector.png new file mode 100644 index 00000000000000..6bd8c06a9e3979 Binary files /dev/null and b/docs/designers-developers/assets/fancy-quote-in-inspector.png differ diff --git a/docs/designers-developers/assets/fancy-quote-with-style.png b/docs/designers-developers/assets/fancy-quote-with-style.png new file mode 100644 index 00000000000000..31f38063a1f1dd Binary files /dev/null and b/docs/designers-developers/assets/fancy-quote-with-style.png differ diff --git a/docs/designers-developers/developers/tutorials/block-tutorial/inspector.png b/docs/designers-developers/assets/inspector.png similarity index 100% rename from docs/designers-developers/developers/tutorials/block-tutorial/inspector.png rename to docs/designers-developers/assets/inspector.png diff --git a/docs/designers-developers/assets/js-tutorial-console-log-error.png b/docs/designers-developers/assets/js-tutorial-console-log-error.png new file mode 100644 index 00000000000000..836a663484192f Binary files /dev/null and b/docs/designers-developers/assets/js-tutorial-console-log-error.png differ diff --git a/docs/designers-developers/assets/js-tutorial-console-log-success.png b/docs/designers-developers/assets/js-tutorial-console-log-success.png new file mode 100644 index 00000000000000..7b42853fb40642 Binary files /dev/null and b/docs/designers-developers/assets/js-tutorial-console-log-success.png differ diff --git a/docs/designers-developers/assets/js-tutorial-error-blocks-undefined.png b/docs/designers-developers/assets/js-tutorial-error-blocks-undefined.png new file mode 100644 index 00000000000000..1f27c36ce75956 Binary files /dev/null and b/docs/designers-developers/assets/js-tutorial-error-blocks-undefined.png differ diff --git a/docs/designers-developers/assets/sidebar-style-and-controls.png b/docs/designers-developers/assets/sidebar-style-and-controls.png new file mode 100644 index 00000000000000..725ddfdd87a4bd Binary files /dev/null and b/docs/designers-developers/assets/sidebar-style-and-controls.png differ diff --git a/docs/designers-developers/assets/sidebar-up-and-running.png b/docs/designers-developers/assets/sidebar-up-and-running.png new file mode 100644 index 00000000000000..12bc2947d48a37 Binary files /dev/null and b/docs/designers-developers/assets/sidebar-up-and-running.png differ diff --git a/docs/designers-developers/assets/toolbar-text.png b/docs/designers-developers/assets/toolbar-text.png new file mode 100644 index 00000000000000..76b18c6b8f368f Binary files /dev/null and b/docs/designers-developers/assets/toolbar-text.png differ diff --git a/docs/designers-developers/assets/toolbar-with-custom-button.png b/docs/designers-developers/assets/toolbar-with-custom-button.png new file mode 100644 index 00000000000000..3b22afaca318ac Binary files /dev/null and b/docs/designers-developers/assets/toolbar-with-custom-button.png differ diff --git a/docs/designers-developers/designers/README.md b/docs/designers-developers/designers/README.md index 362cf794885f20..a630847b37661e 100644 --- a/docs/designers-developers/designers/README.md +++ b/docs/designers-developers/designers/README.md @@ -1,3 +1,3 @@ # Designer Documentation -For those designing blocks and other Block Editor integrations, this documentation will provide resources for creating beautiful and intuitive layouts. +For those designing blocks and other block editor integrations, this documentation will provide resources for creating beautiful and intuitive layouts. diff --git a/docs/designers-developers/designers/animation.md b/docs/designers-developers/designers/animation.md new file mode 100644 index 00000000000000..24948723e0d8c5 --- /dev/null +++ b/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/docs/designers-developers/designers/block-design.md b/docs/designers-developers/designers/block-design.md index ae6abaf4c8a37f..3840c863dd4875 100644 --- a/docs/designers-developers/designers/block-design.md +++ b/docs/designers-developers/designers/block-design.md @@ -17,13 +17,44 @@ Basic block settings won’t always make sense in the context of the placeholder ### The block sidebar should only be used for advanced, tertiary controls -The 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 sidebar as something that only power users may discover. In addition, use sections and headers in the block sidebar if there are more than a handful of options, in order to allow users to easily scan and understand the options available. +The 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 sidebar as something that most users should not need to open. -## Do's and Don'ts +In addition, use sections and headers in the block sidebar if there are more than a handful of options, in order to allow users to easily scan and understand the options available. + +Each block 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 ### Blocks -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. +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. 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. @@ -85,6 +116,10 @@ Because the Drop Cap feature is not necessary for the basic operation of the blo 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: diff --git a/docs/designers-developers/designers/menu-item.md b/docs/designers-developers/designers/menu-item.md new file mode 100644 index 00000000000000..246a8fde1d4570 --- /dev/null +++ b/docs/designers-developers/designers/menu-item.md @@ -0,0 +1,98 @@ +# MenuItem + +`MenuItem` is a component which renders a button intended to be used in combination with the `MenuGroup` component. + +![An image of a MenuItem being highlighted inside of a DropdownMenu component](https://wordpress.org/gutenberg/files/2018/11/MenuItem.png) + +1. MenuItem + +## Table of contents + +1. [Design guidelines](#design-guidelines) +2. [Development guidelines](#development-guidelines) +3. [Related components](#related-components) + +## Design guidelines + +### Usage + +A `MenuGroup` containing `MenuItem`s can be used within a `Dropdown`. A `MenuGroup` can also have other `MenuGroup`s within it so menus can be nested. + +## Development guidelines + +### Usage + +```jsx +import { MenuItem } from '@wordpress/components'; +import { withState } from '@wordpress/compose'; + +const MyMenuItem = withState( { + isActive: true, +} )( ( { isActive, setState } ) => ( + setState( state => ( { isActive: ! state.isActive } ) ) } + > + Toggle + +) ); +``` + +### Props + +`MenuItem` supports the following props. Any additional props are passed through to the underlying [Button](../button) or [IconButton](../icon-button) component. + +#### `children` + +- Type: `WPElement` +- Required: No + +Element to render as child of button. + +Element + +#### `label` + +- Type: `string` +- Required: No + +String to use as primary button label text, applied as `aria-label`. Useful in cases where an `info` prop is passed, where `label` should be the minimal text of the button, described in further detail by `info`. + +Defaults to the value of `children`, if `children` is passed as a string. + +#### `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](../icon-button/README.md#icon). + +#### `shortcut` + +- Type: `string` +- Required: No + +Refer to documentation for [Shortcut's `shortcut` prop](../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. + +## Related components + +- 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. +- `MenuItemsChoice` diff --git a/docs/designers-developers/developers/README.md b/docs/designers-developers/developers/README.md index 5c552b2bd835ee..162c45fbcfa4eb 100644 --- a/docs/designers-developers/developers/README.md +++ b/docs/designers-developers/developers/README.md @@ -1,45 +1,45 @@ # Developer Documentation -Gutenberg is highly flexible, like most of WordPress. You can build custom blocks, modify the editor's appearance, add special plugins, and much more. +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 -Gutenberg is about blocks, and the main extensibility API of Gutenberg is the Block API. It allows you to create your own static blocks, dynamic blocks rendered on the server and also blocks capable of saving data to Post Meta for more structured content. +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 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. +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/reference/hooks/block-filters.md) section. +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](https://github.com/WordPress/gutenberg/blob/master/packages/plugins/README.md) and [Edit Post](https://github.com/WordPress/gutenberg/blob/master/packages/edit-post/README.md) section for more information. +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/reference/hooks/editor-filters.md) page. +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 and Gutenberg plugins is highly encouraged!** +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). -Discover how [Meta Box](../../../docs/designers-developers/developers/backwards-compatibility/meta-box.md) support works in Gutenberg. +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). +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. +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. +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/docs/designers-developers/developers/accessibility.md b/docs/designers-developers/developers/accessibility.md new file mode 100644 index 00000000000000..ab6c0d0066b1dc --- /dev/null +++ b/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/docs/designers-developers/developers/backward-compatibility/README.md b/docs/designers-developers/developers/backward-compatibility/README.md new file mode 100644 index 00000000000000..bd559617c5b17f --- /dev/null +++ b/docs/designers-developers/developers/backward-compatibility/README.md @@ -0,0 +1 @@ +# Backward Compatibility diff --git a/docs/designers-developers/developers/backward-compatibility/deprecations.md b/docs/designers-developers/developers/backward-compatibility/deprecations.md new file mode 100644 index 00000000000000..496a8b3e5d571a --- /dev/null +++ b/docs/designers-developers/developers/backward-compatibility/deprecations.md @@ -0,0 +1,273 @@ +# Deprecations + +The Gutenberg project's deprecation policy is intended to support backward compatibility for releases, when possible. 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_default_post_format_template` 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://wordpress.org/gutenberg/handbook/designers-developers/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/docs/designers-developers/developers/backward-compatibility/meta-box.md b/docs/designers-developers/developers/backward-compatibility/meta-box.md new file mode 100644 index 00000000000000..e7350dadba7fb3 --- /dev/null +++ b/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 Gutenberg. 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 Gutenberg, and explicitly mark it as such. + +If a meta box *doesn't* work with in Gutenberg, 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 Gutenberg 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 Gutenberg 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 Gutenberg Page, 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 Gutenberg, but some meta boxes that include advanced functionality could break. Here are some common reasons why meta boxes might not work as expected in Gutenberg: + +- 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 Gutenberg. +- 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/docs/designers-developers/developers/backwards-compatibility/README.md b/docs/designers-developers/developers/backwards-compatibility/README.md deleted file mode 100644 index bd453cba0e56a4..00000000000000 --- a/docs/designers-developers/developers/backwards-compatibility/README.md +++ /dev/null @@ -1 +0,0 @@ -# Backwards Compatibility diff --git a/docs/designers-developers/developers/backwards-compatibility/deprecations.md b/docs/designers-developers/developers/backwards-compatibility/deprecations.md deleted file mode 100644 index 804f55c0a2c19a..00000000000000 --- a/docs/designers-developers/developers/backwards-compatibility/deprecations.md +++ /dev/null @@ -1,190 +0,0 @@ -# Deprecations - -Gutenberg's deprecation policy is intended to support backwards-compatibility for releases, when possible. 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. - -## 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](https://wordpress.org/gutenberg/handbook/block-api/rich-text-api/), 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: https://github.com/WordPress/gutenberg/blob/master/components/autocomplete/README.md. - - `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: https://wordpress.org/gutenberg/handbook/block-api/attributes/. - - `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/docs/designers-developers/developers/backwards-compatibility/meta-box.md b/docs/designers-developers/developers/backwards-compatibility/meta-box.md deleted file mode 100644 index eb96bfc38e0ffb..00000000000000 --- a/docs/designers-developers/developers/backwards-compatibility/meta-box.md +++ /dev/null @@ -1,84 +0,0 @@ -# Meta Boxes - -This is a brief document detailing how meta box support works in Gutenberg. With the superior developer and user experience of blocks, especially once block templates are available, **porting PHP meta boxes to blocks is highly encouraged!** - -### 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 Gutenberg, and explicitly mark it as such. - -If a meta box *doesn't* work with in Gutenberg, 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 will fall back to the Classic editor, where the meta box will continue working as before. - -Explicitly setting `__block_editor_compatible_meta_box` to `true` will cause WordPress to stay in Gutenberg (assuming another meta box doesn't cause a fallback). - -After a meta box is converted to a block, it can be declared as existing for backwards compatibility: - -```php -add_meta_box( 'my-meta-box', 'My Meta Box', 'my_meta_box_callback', - null, 'normal', 'high', - array( - '__back_compat_meta_box' => false, - ) -); -``` - -When Gutenberg is used, this meta box will no longer be displayed in the meta box area, as it now only exists for backwards compatibility purposes. It will continue to display correctly in the Classic editor, should some other meta box cause a fallback. - -### Meta Box Data Collection - -On each Gutenberg 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 `lib/register.php gutenberg_trick_plugins_into_registering_meta_boxes()` - -`gutenberg_collect_meta_box_data()` is hooked in later on `admin_head`. 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`. - -A copy of the global `$wp_meta_boxes` is made then filtered through `apply_filters( 'filter_gutenberg_meta_boxes', $_meta_boxes_copy );`, which will strip out any core meta boxes, standard custom taxonomy meta boxes, and any meta boxes that have declared themselves as only existing for backwards 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 Gutenberg Page, 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. - -After the new block editor is made into the default editor, it will be necessary to provide the classic-editor flag to access the meta box partial page. - -`gutenberg_meta_box_save()` saves meta box changes. A `meta_box` request parameter should be present and should match one of `'advanced'`, `'normal'`, or `'side'`. This value will determine which meta box area is served. - -So an example url would look like: - -`mysite.com/wp-admin/post.php?post=1&action=edit&meta_box=$location&classic-editor` - -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 Gutenberg, but some meta boxes that include advanced functionality could break. Here are some common reasons why meta boxes might not work as expected in Gutenberg: - -- 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 Gutenberg. -- Plugins making updates to their DOM on "submit" or on "save". diff --git a/docs/designers-developers/developers/block-api/README.md b/docs/designers-developers/developers/block-api/README.md index 2c18a261d8fc5c..56d159bda6d135 100644 --- a/docs/designers-developers/developers/block-api/README.md +++ b/docs/designers-developers/developers/block-api/README.md @@ -1,11 +1,11 @@ # Block API Reference -Blocks are the fundamental element of the Gutenberg editor. They are the primary way in which plugins and themes can register their own functionality and extend the capabilities of the editor. +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. +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). +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/docs/designers-developers/developers/block-api/block-attributes.md b/docs/designers-developers/developers/block-api/block-attributes.md index 9bab342a935001..fd61866f5286c4 100644 --- a/docs/designers-developers/developers/block-api/block-attributes.md +++ b/docs/designers-developers/developers/block-api/block-attributes.md @@ -4,7 +4,7 @@ Attribute sources are used to define the strategy by which 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](../language.md). +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). Each source accepts an optional selector as the first argument. If a selector is specified, the source behavior will be run against the corresponding element(s) contained within the block. Otherwise it will be run against the block's root node. diff --git a/docs/designers-developers/developers/block-api/block-deprecation.md b/docs/designers-developers/developers/block-api/block-deprecation.md index 3fd500c1645417..7e64bfb89a9991 100644 --- a/docs/designers-developers/developers/block-api/block-deprecation.md +++ b/docs/designers-developers/developers/block-api/block-deprecation.md @@ -9,9 +9,9 @@ A block can have several deprecated versions. A deprecation will be tried if a p 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. +- `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 attributes and inner blocks of the parsed block, is expected to return either the attributes compatible with the deprecated block, or a tuple array of `[ attributes, innerBlocks ]`. - `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. @@ -275,4 +275,4 @@ registerBlockType( 'gutenberg/block-with-deprecated-version', { 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](https://github.com/WordPress/gutenberg/tree/master/packages/block-library/src). Core blocks have been updated across releases and contain simple and complex deprecations.* +*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/docs/designers-developers/developers/block-api/block-edit-save.md b/docs/designers-developers/developers/block-api/block-edit-save.md index c6f4d603cc3085..c5e35eddb10a72 100644 --- a/docs/designers-developers/developers/block-api/block-edit-save.md +++ b/docs/designers-developers/developers/block-api/block-edit-save.md @@ -6,25 +6,52 @@ When registering a block, the `edit` and `save` functions provide the interface 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 -// Defining the edit interface -edit() { - return
; +// 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. 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: +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 -// Defining the edit interface -edit( { attributes } ) { +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. @@ -32,23 +59,53 @@ The value of `attributes.content` will be displayed inside the `div` when insert 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 -// Defining the edit interface -edit( { attributes, className } ) { +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 -// Defining the edit interface -edit( { attributes, className, isSelected } ) { +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 (
- { attributes.content } + Your block. { isSelected && Shows only when the block is selected. } @@ -56,14 +113,39 @@ edit( { attributes, className, isSelected } ) { ); } ``` +{% end %} ### setAttributes This function allows the block to update individual attributes based on user interactions. +{% codetabs %} +{% ES5 %} ```js -// Defining the edit interface -edit( { attributes, setAttributes, className, isSelected } ) { +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; @@ -79,6 +161,43 @@ edit( { attributes, setAttributes, className, 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, Gutenberg 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 @@ -87,23 +206,27 @@ The `save` function defines the way in which the different attributes should be {% codetabs %} {% ES5 %} ```js -save() { - return wp.element.createElement( 'hr' ); +save: function() { + return wp.element.createElement( + 'div', + null, + 'Your block.' + ); } ``` {% ESNext %} ```jsx -save() { - return
; +save: () => { + return
Your block.
; } ``` {% end %} -For most blocks, the return value of `save` should be an [instance of WordPress Element](https://github.com/WordPress/gutenberg/blob/master/packages/element/README.md) representing how the block is to appear on the front of the site. +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. -For [dynamic blocks](../../../../docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md), the return value of `save` could either represent a cached copy of the block's content to be shown only in case the plugin implementing the block is ever disabled. Alternatively, return a `null` (empty) value to 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. +For [dynamic blocks](/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md), the return value of `save` could either represent a cached copy of the block's content to be shown only in case the plugin implementing the block is ever disabled. Alternatively, return a `null` (empty) value to 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 @@ -112,7 +235,7 @@ As with `edit`, the `save` function also receives an object argument including a {% codetabs %} {% ES5 %} ```js -save( props ) { +save: function( props ) { return wp.element.createElement( 'div', null, @@ -122,12 +245,136 @@ save( props ) { ``` {% ESNext %} ```jsx -save( { attributes } ) { +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. @@ -153,10 +400,10 @@ The two most common sources of block invalidations are: 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'`). +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-deprecations.md) to learn more about how to accommodate legacy content in intentional markup changes. +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/docs/designers-developers/developers/block-api/block-registration.md b/docs/designers-developers/developers/block-api/block-registration.md index af8aaa36a75ed2..f4a214b741fbe2 100644 --- a/docs/designers-developers/developers/block-api/block-registration.md +++ b/docs/designers-developers/developers/block-api/block-registration.md @@ -66,7 +66,7 @@ The core provided categories are: category: 'widgets', ``` -Plugins and Themes can also register [custom block categories](../docs/extensibility/extending-blocks/#managing-block-categories). +Plugins and Themes can also register [custom block categories](/docs/designers-developers/developers/filters/block-filters.md#managing-block-categories). #### Icon (optional) @@ -82,7 +82,7 @@ icon: 'book-alt', icon: , ``` -**Note:** Custom SVG icons are automatically wrapped in the [`wp.components.SVG` component](https://github.com/WordPress/gutenberg/tree/master/packages/components/src/primitives/svg/) to add accessibility attributes (`aria-hidden`, `role`, and `focusable`). +**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 @@ -104,7 +104,7 @@ icon: { * **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). It is only allowed to add as much as three terms per block. +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. @@ -138,7 +138,7 @@ styles: [ ], ``` -Plugins and Themes can also register [custom block style](../docs/extensibility/extending-blocks/#block-style-variations) for existing blocks. +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) @@ -166,7 +166,7 @@ attributes: { }, ``` -* **See: [Attributes](../docs/block-api/attributes.md).** +* **See: [Attributes](/docs/designers-developers/developers/block-api/block-attributes.md).** #### Transforms (optional) @@ -184,9 +184,9 @@ transforms: { { type: 'block', blocks: [ 'core/paragraph' ], - transform: function ( content ) { + transform: function ( attributes ) { return createBlock( 'core/heading', { - content, + content: attributes.content, } ); }, }, @@ -284,9 +284,9 @@ transforms: { { type: 'block', blocks: [ 'core/paragraph' ], - transform: function( content ) { + transform: function( attributes ) { return createBlock( 'core/paragraph', { - content, + content: attributes.content, } ); }, }, @@ -311,6 +311,39 @@ transforms: { ``` {% 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 %} @@ -321,12 +354,12 @@ transforms: { { type: 'block', blocks: [ 'core/paragraph' ], - isMatch: function( attribute ) { + isMatch: function( attributes ) { return attributes.isText; }, - transform: function( content ) { + transform: function( attributes ) { return createBlock( 'core/paragraph', { - content, + content: attributes.content, } ); }, }, @@ -453,7 +486,7 @@ transforms: { * **Type:** `Array` -Blocks are able to be inserted into blocks that use [`InnerBlocks`](https://github.com/WordPress/gutenberg/blob/master/packages/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. +Blocks are able to be inserted into blocks that use [`InnerBlocks`](/packages/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. @@ -492,7 +525,7 @@ attributes: { } ``` -- `alignWide` (default `true`): This property allows to enable [wide alignment](../docs/extensibility/theme-support.md#wide-alignment) for your theme. To disable this behavior for a single block, set this flag to `false`. +- `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. diff --git a/docs/designers-developers/developers/block-api/block-templates.md b/docs/designers-developers/developers/block-api/block-templates.md index a26d822d0100e5..caa96ff20b5bd4 100644 --- a/docs/designers-developers/developers/block-api/block-templates.md +++ b/docs/designers-developers/developers/block-api/block-templates.md @@ -17,18 +17,50 @@ Planned additions: 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/editor/src/components/inner-blocks) and templates, when inserted creates a set of blocks based off the template. + ```js -const template = [ - [ 'block/name', {} ], // [ blockName, attributes ] +const el = wp.element.createElement; +const { registerBlockType } = wp.blocks; +const { InnerBlocks } = wp.editor; + +const BLOCKS_TEMPLATE = [ + [ 'core/image', {} ], + [ 'core/paragraph', { placeholder: 'Image Details' } ], ]; -``` -```php -'template' => array( - array( 'block/name' ), -), +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: @@ -61,20 +93,7 @@ add_action( 'init', 'myplugin_register_book_post_type' ); 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 -'template_lock' => 'all', // or 'insert' to allow moving -``` - -*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. - -## Existing Post Types - -It is also possible to assign a template to an existing post type like "posts" and "pages": - -```php -function my_add_template_to_posts() { +function myplugin_register_template() { $post_type_object = get_post_type_object( 'post' ); $post_type_object->template = array( array( 'core/paragraph', array( @@ -83,12 +102,17 @@ function my_add_template_to_posts() { ); $post_type_object->template_lock = 'all'; } -add_action( 'init', 'my_add_template_to_posts' ); +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 assigned a nested template to the block. +Container blocks like the columns blocks also support templates. This is achieved by assigning a nested template to the block. ```php $template = array( diff --git a/docs/designers-developers/developers/data/README.md b/docs/designers-developers/developers/data/README.md index 5233753c1445e5..7408d171144cf4 100644 --- a/docs/designers-developers/developers/data/README.md +++ b/docs/designers-developers/developers/data/README.md @@ -1,10 +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/editor**: The 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 + - [**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/docs/designers-developers/developers/data/data-core-block-editor.md b/docs/designers-developers/developers/data/data-core-block-editor.md new file mode 100644 index 00000000000000..03929e3671685d --- /dev/null +++ b/docs/designers-developers/developers/data/data-core-block-editor.md @@ -0,0 +1,1050 @@ +# **core/block-editor**: The Block Editor’s Data + +## Selectors + +### getBlockDependantsCacheBust + +Returns a new reference when the inner blocks of a given block client ID +change. This is used exclusively as a memoized selector dependant, relying +on this selector's shared return value and recursively those of its inner +blocks defined as dependencies. This abuses mechanics of the selector +memoization to return from the original selector function only when +dependants change. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +### getBlockName + +Returns a block's name given its client ID, or null if no block exists with +the client ID. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Block name. + +### isBlockValid + +Returns whether a block is valid or not. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Is Valid. + +### getBlockAttributes + +Returns a block's attributes given its client ID, or null if no block exists with +the client ID. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Block attributes. + +### 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: Editor state. + * clientId: Block client ID. + +*Returns* + +Parsed block object. + +### 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: Editor state. + * rootClientId: Optional root client ID of block list. + +*Returns* + +Post blocks. + +### getClientIdsOfDescendants + +Returns an array containing the clientIds of all descendants +of the blocks given. + +*Parameters* + + * state: Global application state. + * clientIds: Array of blocks to inspect. + +*Returns* + +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: Global application state. + +*Returns* + +ids of top-level and descendant blocks. + +### 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: Global application state. + * blockName: Optional block name, if specified only blocks of that type will be counted. + +*Returns* + +Number of blocks in the post, or number of blocks with name equal to blockName. + +### getBlocksByClientId + +Given an array of block client IDs, returns the corresponding array of block +objects. + +*Parameters* + + * state: Editor state. + * clientIds: Client IDs for which blocks are to be returned. + +*Returns* + +Block objects. + +### getBlockCount + +Returns the number of blocks currently present in the post. + +*Parameters* + + * state: Editor state. + * rootClientId: Optional root client ID of block list. + +*Returns* + +Number of blocks in the post. + +### 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: Global application state. + +*Returns* + +Client ID of block selection start. + +### 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: Global application state. + +*Returns* + +Client ID of block selection end. + +### getSelectedBlockCount + +Returns the number of blocks currently selected in the post. + +*Parameters* + + * state: Global application state. + +*Returns* + +Number of blocks selected in the post. + +### hasSelectedBlock + +Returns true if there is a single selected block, or false otherwise. + +*Parameters* + + * state: Editor state. + +*Returns* + +Whether a single block is selected. + +### getSelectedBlockClientId + +Returns the currently selected block client ID, or null if there is no +selected block. + +*Parameters* + + * state: Editor state. + +*Returns* + +Selected block client ID. + +### getSelectedBlock + +Returns the currently selected block, or null if there is no selected block. + +*Parameters* + + * state: Global application state. + +*Returns* + +Selected block. + +### 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: Editor state. + * clientId: Block from which to find root client ID. + +*Returns* + +Root client ID, if exists + +### 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: Editor state. + * clientId: Block from which to find root client ID. + +*Returns* + +Root client ID + +### 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: Editor state. + * startClientId: Optional client ID of block from which to + search. + * modifier: Directionality multiplier (1 next, -1 + previous). + +*Returns* + +Return the client ID of the block, 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: Editor state. + * startClientId: Optional client ID of block from which to + search. + +*Returns* + +Adjacent block's client ID, or null if none exists. + +### 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: Editor state. + * startClientId: Optional client ID of block from which to + search. + +*Returns* + +Adjacent block's client ID, or null if none exists. + +### 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: Global application state. + +*Returns* + +Selected block. + +### getMultiSelectedBlockClientIds + +Returns the current multi-selection set of block client IDs, or an empty +array if there is no multi-selection. + +*Parameters* + + * state: Editor state. + +*Returns* + +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: Editor state. + +*Returns* + +Multi-selected block objects. + +### getFirstMultiSelectedBlockClientId + +Returns the client ID of the first block in the multi-selection set, or null +if there is no multi-selection. + +*Parameters* + + * state: Editor state. + +*Returns* + +First block client ID in the multi-selection set. + +### getLastMultiSelectedBlockClientId + +Returns the client ID of the last block in the multi-selection set, or null +if there is no multi-selection. + +*Parameters* + + * state: Editor state. + +*Returns* + +Last block client ID in the multi-selection set. + +### 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: Editor state. + * clientId: Block client ID. + +*Returns* + +Whether block is first in multi-selection. + +### isBlockMultiSelected + +Returns true if the client ID occurs within the block multi-selection, or +false otherwise. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Whether block is in multi-selection set. + +### isAncestorMultiSelected + +Returns true if an ancestor of the block is multi-selected, or false +otherwise. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Whether an ancestor of the block is in multi-selection + set. + +### 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. + +*Parameters* + + * state: Editor state. + +*Returns* + +Client ID of block beginning multi-selection. + +### 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. + +*Parameters* + + * state: Editor state. + +*Returns* + +Client ID of block ending multi-selection. + +### 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: Editor state. + * rootClientId: Optional root client ID of block list. + +*Returns* + +Ordered client IDs of editor blocks. + +### 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: Editor state. + * clientId: Block client ID. + * rootClientId: Optional root client ID of block list. + +*Returns* + +Index at which block exists in order. + +### 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: Editor state. + * clientId: Block client ID. + +*Returns* + +Whether block is selected and multi-selection exists. + +### hasSelectedInnerBlock + +Returns true if one of the block's inner blocks is selected. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + * deep: Perform a deep check. + +*Returns* + +Whether the block as an inner block selected + +### 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: Editor state. + * clientId: Block client ID. + +*Returns* + +Whether block is selected and not the last in the + selection. + +### hasMultiSelection + +Returns true if a multi-selection has been made, or false otherwise. + +*Parameters* + + * state: Editor state. + +*Returns* + +Whether multi-selection has been made. + +### 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. + +*Parameters* + + * state: Global application state. + +*Returns* + +True if multi-selecting, false if not. + +### isSelectionEnabled + +Selector that returns if multi-selection is enabled or not. + +*Parameters* + + * state: Global application state. + +*Returns* + +True if it should be possible to multi-select blocks, false if multi-selection is disabled. + +### getBlockMode + +Returns the block's editing mode, defaulting to "visual" if not explicitly +assigned. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Block editing mode. + +### isTyping + +Returns true if the user is typing, or false otherwise. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether user is typing. + +### isCaretWithinFormattedText + +Returns true if the caret is within formatted text, or false otherwise. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether the caret is within formatted text. + +### getBlockInsertionPoint + +Returns the insertion point, the index at which the new inserted block would +be placed. Defaults to the last index. + +*Parameters* + + * state: Editor state. + +*Returns* + +Insertion point object with `rootClientId`, `index`. + +### isBlockInsertionPointVisible + +Returns true if we should show the block insertion point. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether the insertion point is visible or not. + +### isValidTemplate + +Returns whether the blocks matches the template or not. + +*Parameters* + + * state: null + +*Returns* + +Whether the template is valid or not. + +### getTemplate + +Returns the defined block template + +*Parameters* + + * state: null + +*Returns* + +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: Editor state. + * rootClientId: Optional block root client ID. + +*Returns* + +Block Template Lock + +### canInsertBlockType + +Determines if the given block type is allowed to be inserted into the block list. + +*Parameters* + + * state: Editor state. + * blockName: The name of the block type, e.g.' core/paragraph'. + * rootClientId: Optional root client ID of block list. + +*Returns* + +Whether the given block type is allowed to be inserted. + +### 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 (https://en.wikipedia.org/wiki/Frecency) +that combines block usage frequenty and recency. + +Items are returned ordered descendingly by their 'utility' and 'frecency'. + +*Parameters* + + * state: Editor state. + * rootClientId: Optional root client ID of block list. + +*Returns* + +Items that appear in inserter. + +### hasInserterItems + +Determines whether there are items to show in the inserter. + +*Parameters* + + * state: Editor state. + * rootClientId: Optional root client ID of block list. + +*Returns* + +Items that appear in inserter. + +### getBlockListSettings + +Returns the Block List settings of a block, if any exist. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Block settings of the block if set. + +### getSettings + +Returns the editor settings. + +*Parameters* + + * state: Editor state. + +*Returns* + +The editor settings object. + +### 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: Block editor state. + +*Returns* + +Whether the most recent block change was persistent. + +## Actions + +### 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 of blocks. + +### 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 of block objects. + +### updateBlockAttributes + +Returns an action object used in signalling that the block attributes with +the specified client ID has been updated. + +*Parameters* + + * clientId: Block client ID. + * attributes: Block attributes to be merged. + +### updateBlock + +Returns an action object used in signalling that the block with the +specified client ID has been updated. + +*Parameters* + + * clientId: Block client ID. + * updates: Block attributes to be merged. + +### 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: Block client ID. + * initialPosition: Optional initial position. Pass as -1 to + reflect reverse selection. + +### selectPreviousBlock + +Yields action objects used in signalling that the block preceding the given +clientId should be selected. + +*Parameters* + + * clientId: Block client ID. + +### selectNextBlock + +Yields action objects used in signalling that the block following the given +clientId should be selected. + +*Parameters* + + * clientId: Block client ID. + +### startMultiSelect + +Returns an action object used in signalling that a block multi-selection has started. + +### stopMultiSelect + +Returns an action object used in signalling that block multi-selection stopped. + +### multiSelect + +Returns an action object used in signalling that block multi-selection changed. + +*Parameters* + + * start: First block of the multi selection. + * end: Last block of the multiselection. + +### clearSelectedBlock + +Returns an action object used in signalling that the block selection is cleared. + +### toggleSelection + +Returns an action object that enables or disables block selection. + +*Parameters* + + * boolean: [isSelectionEnabled=true] Whether block selection should + be enabled. + +### replaceBlocks + +Returns an action object signalling that a blocks should be replaced with +one or more replacement blocks. + +*Parameters* + + * clientIds: Block client ID(s) to replace. + * blocks: Replacement block(s). + +### replaceBlock + +Returns an action object signalling that a single block should be replaced +with one or more replacement blocks. + +*Parameters* + + * clientId: Block client ID to replace. + * block: Replacement block(s). + +### moveBlockToPosition + +Returns an action object signalling that an indexed block should be moved +to a new index. + +*Parameters* + + * clientId: The client ID of the block. + * fromRootClientId: Root client ID source. + * toRootClientId: Root client ID destination. + * index: The index to move the block into. + +### 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: Block object to insert. + * index: Index at which block should be inserted. + * rootClientId: Optional root client ID of block list on which to insert. + * updateSelection: If true block selection will be updated. If false, block selection will not change. Defaults to true. + +### 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: Block objects to insert. + * index: Index at which block should be inserted. + * rootClientId: Optional root client ID of block list on which to insert. + * updateSelection: If true block selection will be updated. If false, block selection will not change. Defaults to true. + +### showInsertionPoint + +Returns an action object used in signalling that the insertion point should +be shown. + +*Parameters* + + * rootClientId: Optional root client ID of block list on + which to insert. + * index: Index at which block should be inserted. + +### hideInsertionPoint + +Returns an action object hiding the insertion point. + +### setTemplateValidity + +Returns an action object resetting the template validity. + +*Parameters* + + * isValid: template validity flag. + +### synchronizeTemplate + +Returns an action object synchronize the template with the list of blocks + +### mergeBlocks + +Returns an action object used in signalling that two blocks should be merged + +*Parameters* + + * firstBlockClientId: Client ID of the first block to merge. + * secondBlockClientId: Client ID of the second block to merge. + +### removeBlocks + +Yields action objects used in signalling that the blocks corresponding to +the set of specified client IDs are to be removed. + +*Parameters* + + * clientIds: Client IDs of blocks to remove. + * selectPrevious: True if the previous block should be + selected when a block is removed. + +### removeBlock + +Returns an action object used in signalling that the block with the +specified client ID is to be removed. + +*Parameters* + + * clientId: Client ID of block to remove. + * selectPrevious: True if the previous block should be + selected when a block is removed. + +### toggleBlockMode + +Returns an action object used to toggle the block editing mode between +visual and HTML modes. + +*Parameters* + + * clientId: Block client ID. + +### startTyping + +Returns an action object used in signalling that the user has begun to type. + +### stopTyping + +Returns an action object used in signalling that the user has stopped typing. + +### enterFormattedText + +Returns an action object used in signalling that the caret has entered formatted text. + +### exitFormattedText + +Returns an action object used in signalling that the user caret has exited formatted text. + +### 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: Optional attributes of the block to assign. + * rootClientId: Optional root client ID of block list on which + to append. + * index: Optional index where to insert the default block + +### updateBlockListSettings + +Returns an action object that changes the nested settings of a given block. + +*Parameters* + + * clientId: Client ID of the block whose nested setting are + being received. + * settings: Object with the new settings for the nested block. + +### updateSettings + +Returns an action object used in signalling that the block editor settings have been updated. + +*Parameters* + + * settings: Updated settings + +### __unstableSaveReusableBlock + +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. + +*Parameters* + + * id: Reusable block's id. + * updatedId: Updated block's id. + +### __unstableMarkLastChangeAsPersistent + +Returns an action object used in signalling that the last block change should be marked explicitely as persistent. \ No newline at end of file diff --git a/docs/designers-developers/developers/data/data-core-blocks.md b/docs/designers-developers/developers/data/data-core-blocks.md index abdb30b903773c..c7a63e769f350f 100644 --- a/docs/designers-developers/developers/data/data-core-blocks.md +++ b/docs/designers-developers/developers/data/data-core-blocks.md @@ -129,6 +129,21 @@ Returns true if the block defines support for a feature, or false otherwise. Whether block supports feature. +### isMatchingSearchTerm + +Returns true if the block type by the given name or object value matches a +search term, or false otherwise. + +*Parameters* + + * state: Blocks state. + * nameOrType: Block name or type object. + * searchTerm: Search term by which to filter. + +*Returns* + +Wheter block type matches search term. + ### hasChildBlocks Returns a boolean indicating if a block has child blocks or not. diff --git a/docs/designers-developers/developers/data/data-core-edit-post.md b/docs/designers-developers/developers/data/data-core-edit-post.md index c12cf62a83d633..f36cf5bc47068f 100644 --- a/docs/designers-developers/developers/data/data-core-edit-post.md +++ b/docs/designers-developers/developers/data/data-core-edit-post.md @@ -339,6 +339,24 @@ Returns an action object used to toggle a plugin name flag. * pluginName: Plugin name. +### hideBlockTypes + +Returns an action object used in signalling that block types by the given +name(s) should be hidden. + +*Parameters* + + * blockNames: Names of block types to hide. + +### showBlockTypes + +Returns an action object used in signalling that block types by the given +name(s) should be shown. + +*Parameters* + + * blockNames: Names of block types to show. + ### setAvailableMetaBoxesPerLocation Returns an action object used in signaling diff --git a/docs/designers-developers/developers/data/data-core-editor.md b/docs/designers-developers/developers/data/data-core-editor.md index 800537ba65d225..cd251dc92d6787 100644 --- a/docs/designers-developers/developers/data/data-core-editor.md +++ b/docs/designers-developers/developers/data/data-core-editor.md @@ -1,4 +1,4 @@ -# **core/editor**: The Editor’s Data +# **core/editor**: The Post Editor’s Data ## Selectors @@ -365,676 +365,6 @@ and modified date are the same. Whether the edited post has a floating date value. -### getBlockDependantsCacheBust - -Returns a new reference when the inner blocks of a given block client ID -change. This is used exclusively as a memoized selector dependant, relying -on this selector's shared return value and recursively those of its inner -blocks defined as dependencies. This abuses mechanics of the selector -memoization to return from the original selector function only when -dependants change. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -A value whose reference will change only when inner blocks of - the given block client ID change. - -### getBlockName - -Returns a block's name given its client ID, or null if no block exists with -the client ID. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Block name. - -### isBlockValid - -Returns whether a block is valid or not. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Is Valid. - -### getBlockAttributes - -Returns a block's attributes given its client ID, or null if no block exists with -the client ID. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Block attributes. - -### 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: Editor state. - * clientId: Block client ID. - -*Returns* - -Parsed block object. - -### 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: Editor state. - * rootClientId: Optional root client ID of block list. - -*Returns* - -Post blocks. - -### getClientIdsOfDescendants - -Returns an array containing the clientIds of all descendants -of the blocks given. - -*Parameters* - - * state: Global application state. - * clientIds: Array of blocks to inspect. - -*Returns* - -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: Global application state. - -*Returns* - -ids of top-level and descendant blocks. - -### 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: Global application state. - * blockName: Optional block name, if specified only blocks of that type will be counted. - -*Returns* - -Number of blocks in the post, or number of blocks with name equal to blockName. - -### getBlocksByClientId - -Given an array of block client IDs, returns the corresponding array of block -objects. - -*Parameters* - - * state: Editor state. - * clientIds: Client IDs for which blocks are to be returned. - -*Returns* - -Block objects. - -### getBlockCount - -Returns the number of blocks currently present in the post. - -*Parameters* - - * state: Editor state. - * rootClientId: Optional root client ID of block list. - -*Returns* - -Number of blocks in the post. - -### 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: Global application state. - -*Returns* - -Client ID of block selection start. - -### 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: Global application state. - -*Returns* - -Client ID of block selection end. - -### getSelectedBlockCount - -Returns the number of blocks currently selected in the post. - -*Parameters* - - * state: Global application state. - -*Returns* - -Number of blocks selected in the post. - -### hasSelectedBlock - -Returns true if there is a single selected block, or false otherwise. - -*Parameters* - - * state: Editor state. - -*Returns* - -Whether a single block is selected. - -### getSelectedBlockClientId - -Returns the currently selected block client ID, or null if there is no -selected block. - -*Parameters* - - * state: Editor state. - -*Returns* - -Selected block client ID. - -### getSelectedBlock - -Returns the currently selected block, or null if there is no selected block. - -*Parameters* - - * state: Global application state. - -*Returns* - -Selected block. - -### 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: Editor state. - * clientId: Block from which to find root client ID. - -*Returns* - -Root client ID, if exists - -### 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: Editor state. - * clientId: Block from which to find root client ID. - -*Returns* - -Root client ID - -### 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: Editor state. - * startClientId: Optional client ID of block from which to - search. - * modifier: Directionality multiplier (1 next, -1 - previous). - -*Returns* - -Return the client ID of the block, 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: Editor state. - * startClientId: Optional client ID of block from which to - search. - -*Returns* - -Adjacent block's client ID, or null if none exists. - -### 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: Editor state. - * startClientId: Optional client ID of block from which to - search. - -*Returns* - -Adjacent block's client ID, or null if none exists. - -### 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: Global application state. - -*Returns* - -Selected block. - -### getMultiSelectedBlockClientIds - -Returns the current multi-selection set of block client IDs, or an empty -array if there is no multi-selection. - -*Parameters* - - * state: Editor state. - -*Returns* - -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: Editor state. - -*Returns* - -Multi-selected block objects. - -### getFirstMultiSelectedBlockClientId - -Returns the client ID of the first block in the multi-selection set, or null -if there is no multi-selection. - -*Parameters* - - * state: Editor state. - -*Returns* - -First block client ID in the multi-selection set. - -### getLastMultiSelectedBlockClientId - -Returns the client ID of the last block in the multi-selection set, or null -if there is no multi-selection. - -*Parameters* - - * state: Editor state. - -*Returns* - -Last block client ID in the multi-selection set. - -### 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: Editor state. - * clientId: Block client ID. - -*Returns* - -Whether block is first in multi-selection. - -### isBlockMultiSelected - -Returns true if the client ID occurs within the block multi-selection, or -false otherwise. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Whether block is in multi-selection set. - -### isAncestorMultiSelected - -Returns true if an ancestor of the block is multi-selected, or false -otherwise. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Whether an ancestor of the block is in multi-selection - set. - -### 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. - -*Parameters* - - * state: Editor state. - -*Returns* - -Client ID of block beginning multi-selection. - -### 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. - -*Parameters* - - * state: Editor state. - -*Returns* - -Client ID of block ending multi-selection. - -### 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: Editor state. - * rootClientId: Optional root client ID of block list. - -*Returns* - -Ordered client IDs of editor blocks. - -### 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: Editor state. - * clientId: Block client ID. - * rootClientId: Optional root client ID of block list. - -*Returns* - -Index at which block exists in order. - -### 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: Editor state. - * clientId: Block client ID. - -*Returns* - -Whether block is selected and multi-selection exists. - -### hasSelectedInnerBlock - -Returns true if one of the block's inner blocks is selected. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - * deep: Perform a deep check. - -*Returns* - -Whether the block as an inner block selected - -### 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: Editor state. - * clientId: Block client ID. - -*Returns* - -Whether block is selected and not the last in the - selection. - -### hasMultiSelection - -Returns true if a multi-selection has been made, or false otherwise. - -*Parameters* - - * state: Editor state. - -*Returns* - -Whether multi-selection has been made. - -### 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. - -*Parameters* - - * state: Global application state. - -*Returns* - -True if multi-selecting, false if not. - -### isSelectionEnabled - -Selector that returns if multi-selection is enabled or not. - -*Parameters* - - * state: Global application state. - -*Returns* - -True if it should be possible to multi-select blocks, false if multi-selection is disabled. - -### getBlockMode - -Returns the block's editing mode, defaulting to "visual" if not explicitly -assigned. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Block editing mode. - -### isTyping - -Returns true if the user is typing, or false otherwise. - -*Parameters* - - * state: Global application state. - -*Returns* - -Whether user is typing. - -### isCaretWithinFormattedText - -Returns true if the caret is within formatted text, or false otherwise. - -*Parameters* - - * state: Global application state. - -*Returns* - -Whether the caret is within formatted text. - -### getBlockInsertionPoint - -Returns the insertion point, the index at which the new inserted block would -be placed. Defaults to the last index. - -*Parameters* - - * state: Editor state. - -*Returns* - -Insertion point object with `rootClientId`, `index`. - -### isBlockInsertionPointVisible - -Returns true if we should show the block insertion point. - -*Parameters* - - * state: Global application state. - -*Returns* - -Whether the insertion point is visible or not. - -### isValidTemplate - -Returns whether the blocks matches the template or not. - -*Parameters* - - * state: null - -*Returns* - -Whether the template is valid or not. - -### getTemplate - -Returns the defined block template - -*Parameters* - - * state: null - -*Returns* - -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: Editor state. - * rootClientId: Optional block root client ID. - -*Returns* - -Block Template Lock - ### isSavingPost Returns true if the post is currently being saved, or false otherwise. @@ -1149,63 +479,6 @@ before falling back to serialization of block state. Post content. -### canInsertBlockType - -Determines if the given block type is allowed to be inserted into the block list. - -*Parameters* - - * state: Editor state. - * blockName: The name of the block type, e.g.' core/paragraph'. - * rootClientId: Optional root client ID of block list. - -*Returns* - -Whether the given block type is allowed to be inserted. - -### 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 (https://en.wikipedia.org/wiki/Frecency) -that combines block usage frequenty and recency. - -Items are returned ordered descendingly by their 'utility' and 'frecency'. - -*Parameters* - - * state: Editor state. - * rootClientId: Optional root client ID of block list. - -*Returns* - -Items that appear in inserter. - -### hasInserterItems - -Determines whether there are items to show in the inserter. - -*Parameters* - - * state: Editor state. - * rootClientId: Optional root client ID of block list. - -*Returns* - -Items that appear in inserter. - ### __experimentalGetReusableBlock Returns the reusable block with the given ID. @@ -1336,44 +609,6 @@ before state satisfies the given predicate function. Whether predicate matches for some history. -### getBlockListSettings - -Returns the Block List settings of a block, if any exist. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Block settings of the block if set. - -### getEditorSettings - -Returns the editor settings. - -*Parameters* - - * state: Editor state. - -*Returns* - -The editor settings object. - -### getTokenSettings - -Returns the token settings. - -*Parameters* - - * state: Editor state. - * name: Token name. - -*Returns* - -Token settings object, or the named token settings object if set. - ### isPostLocked Returns whether the post is locked. @@ -1459,229 +694,165 @@ or skipped when the user clicks the "publish" button. Whether the pre-publish panel should be shown or not. -## Actions - -### setupEditor - -Returns an action object used in signalling that editor has initialized with -the specified post object and editor settings. - -*Parameters* - - * post: Post object. - * edits: Initial edited attributes object. - -### 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: Post object. - -### resetAutosave - -Returns an action object used in signalling that the latest autosave of the -post has been received, by initialization or autosave. - -*Parameters* - - * post: Autosave post object. +### getEditorBlocks -### updatePost - -Returns an action object used in signalling that a patch of updates for the -latest version of the post have been received. +Return the current block list. *Parameters* - * edits: Updated post fields. - -### setupEditorState - -Returns an action object used to setup the editor state when first opening an editor. + * state: null -*Parameters* +*Returns* - * post: Post object. - * blocks: Array of blocks. +Block list. -### resetBlocks +### __unstableIsEditorReady -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. +Is the editor ready *Parameters* - * blocks: Array of blocks. - -### 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. + * state: null -*Parameters* +*Returns* - * blocks: Array of block objects. +is Ready. -### updateBlockAttributes +### getEditorSettings -Returns an action object used in signalling that the block attributes with -the specified client ID has been updated. +Returns the post editor settings. *Parameters* - * clientId: Block client ID. - * attributes: Block attributes to be merged. - -### updateBlock + * state: Editor state. -Returns an action object used in signalling that the block with the -specified client ID has been updated. +*Returns* -*Parameters* +The editor settings object. - * clientId: Block client ID. - * updates: Block attributes to be merged. +## Actions -### selectBlock +### setupEditor -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. +Returns an action object used in signalling that editor has initialized with +the specified post object and editor settings. *Parameters* - * clientId: Block client ID. - * initialPosition: Optional initial position. Pass as -1 to - reflect reverse selection. + * post: Post object. + * edits: Initial edited attributes object. + * template: Block Template. -### toggleSelection +### resetPost -Returns an action object that enables or disables block selection. +Returns an action object used in signalling that the latest version of the +post has been received, either by initialization or save. *Parameters* - * boolean: [isSelectionEnabled=true] Whether block selection should - be enabled. + * post: Post object. -### replaceBlocks +### resetAutosave -Returns an action object signalling that a blocks should be replaced with -one or more replacement blocks. +Returns an action object used in signalling that the latest autosave of the +post has been received, by initialization or autosave. *Parameters* - * clientIds: Block client ID(s) to replace. - * blocks: Replacement block(s). + * post: Autosave post object. -### replaceBlock +### __experimentalRequestPostUpdateStart -Returns an action object signalling that a single block should be replaced -with one or more replacement blocks. +Optimistic action for dispatching that a post update request has started. *Parameters* - * clientId: Block client ID to replace. - * block: Replacement block(s). + * options: null -### moveBlockToPosition +### __experimentalRequestPostUpdateSuccess -Returns an action object signalling that an indexed block should be moved -to a new index. +Optimistic action for indicating that the request post update has completed +successfully. *Parameters* - * clientId: The client ID of the block. - * fromRootClientId: Root client ID source. - * toRootClientId: Root client ID destination. - * index: The index to move the block into. + * data: The data for the action. + * data.previousPost: The previous post prior to update. + * data.post: The new post after update + * data.isRevision: Whether the post is a revision or not. + * data.options: Options passed through from the original + action dispatch. + * data.postType: The post type object. -### insertBlock +### __experimentalRequestPostUpdateFailure -Returns an action object used in signalling that a single block should be -inserted, optionally at a specific index respective a root block list. +Optimistic action for indicating that the request post update has completed +with a failure. *Parameters* - * block: Block object to insert. - * index: Index at which block should be inserted. - * rootClientId: Optional root client ID of block list on which to insert. - * updateSelection: If true block selection will be updated. If false, block selection will not change. Defaults to true. + * data: The data for the action + * data.post: The post that failed updating. + * data.edits: The fields that were being updated. + * data.error: The error from the failed call. + * data.options: Options passed through from the original + action dispatch. -### insertBlocks +### updatePost -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. +Returns an action object used in signalling that a patch of updates for the +latest version of the post have been received. *Parameters* - * blocks: Block objects to insert. - * index: Index at which block should be inserted. - * rootClientId: Optional root cliente ID of block list on which to insert. - * updateSelection: If true block selection will be updated. If false, block selection will not change. Defaults to true. + * edits: Updated post fields. -### showInsertionPoint +### setupEditorState -Returns an action object used in signalling that the insertion point should -be shown. +Returns an action object used to setup the editor state when first opening +an editor. *Parameters* - * rootClientId: Optional root client ID of block list on - which to insert. - * index: Index at which block should be inserted. - -### hideInsertionPoint - -Returns an action object hiding the insertion point. + * post: Post object. -### setTemplateValidity +### editPost -Returns an action object resetting the template validity. +Returns an action object used in signalling that attributes of the post have +been edited. *Parameters* - * isValid: template validity flag. - -### synchronizeTemplate - -Returns an action object synchronize the template with the list of blocks + * edits: Post attributes to edit. -### editPost +### __experimentalOptimisticUpdatePost -Returns an action object used in signalling that attributes of the post have -been edited. +Returns action object produced by the updatePost creator augmented by +an optimist option that signals optimistically applying updates. *Parameters* - * edits: Post attributes to edit. + * edits: Updated post fields. ### savePost -Returns an action object to save the post. +Action generator for saving the current post in the editor. *Parameters* - * options: Options for the save. - * options.isAutosave: Perform an autosave if true. + * options: null -### mergeBlocks +### refreshPost -Returns an action object used in signalling that two blocks should be merged +Action generator for handling refreshing the current post. -*Parameters* +### trashPost - * firstBlockClientId: Client ID of the first block to merge. - * secondBlockClientId: Client ID of the second block to merge. +Action generator for trashing the current post in the editor. ### autosave -Returns an action object used in signalling that the post should autosave. +Action generator used in signalling that the post should autosave. *Parameters* @@ -1701,53 +872,6 @@ Returns an action object used in signalling that undo history should pop. Returns an action object used in signalling that undo history record should be created. -### removeBlocks - -Returns an action object used in signalling that the blocks corresponding to -the set of specified client IDs are to be removed. - -*Parameters* - - * clientIds: Client IDs of blocks to remove. - * selectPrevious: True if the previous block should be - selected when a block is removed. - -### removeBlock - -Returns an action object used in signalling that the block with the -specified client ID is to be removed. - -*Parameters* - - * clientId: Client ID of block to remove. - * selectPrevious: True if the previous block should be - selected when a block is removed. - -### toggleBlockMode - -Returns an action object used to toggle the block editing mode between -visual and HTML modes. - -*Parameters* - - * clientId: Block client ID. - -### startTyping - -Returns an action object used in signalling that the user has begun to type. - -### stopTyping - -Returns an action object used in signalling that the user has stopped typing. - -### enterFormattedText - -Returns an action object used in signalling that the caret has entered formatted text. - -### exitFormattedText - -Returns an action object used in signalling that the user caret has exited formatted text. - ### updatePostLock Returns an action object used to lock the editor. @@ -1806,7 +930,8 @@ to be updated. ### __experimentalConvertBlockToStatic -Returns an action object used to convert a reusable block into a static block. +Returns an action object used to convert a reusable block into a static +block. *Parameters* @@ -1814,62 +939,52 @@ Returns an action object used to convert a reusable block into a static block. ### __experimentalConvertBlockToReusable -Returns an action object used to convert a static block into a reusable block. +Returns an action object used to convert a static block into a reusable +block. *Parameters* * clientIds: The client IDs of the block to detach. -### insertDefaultBlock +### enablePublishSidebar -Returns an action object used in signalling that a new block of the default -type should be added to the block list. +Returns an action object used in signalling that the user has enabled the +publish sidebar. -*Parameters* +### disablePublishSidebar - * attributes: Optional attributes of the block to assign. - * rootClientId: Optional root client ID of block list on which - to append. - * index: Optional index where to insert the default block +Returns an action object used in signalling that the user has disabled the +publish sidebar. -### updateBlockListSettings +### lockPostSaving -Returns an action object that changes the nested settings of a given block. +Returns an action object used to signal that post saving is locked. *Parameters* - * clientId: Client ID of the block whose nested setting are - being received. - * settings: Object with the new settings for the nested block. + * lockName: The lock name. -### updateEditorSettings +### unlockPostSaving -Returns an action object used in signalling that the editor settings have been updated. +Returns an action object used to signal that post saving is unlocked. *Parameters* - * settings: Updated settings - -### enablePublishSidebar - -Returns an action object used in signalling that the user has enabled the publish sidebar. - -### disablePublishSidebar - -Returns an action object used in signalling that the user has disabled the publish sidebar. + * lockName: The lock name. -### lockPostSaving +### resetEditorBlocks -Returns an action object used to signal that post saving is locked. +Returns an action object used to signal that the blocks have been updated. *Parameters* - * lockName: The lock name. + * blocks: Block Array. + * options: Optional options. -### unlockPostSaving +### updateEditorSettings -Returns an action object used to signal that post saving is unlocked. +Returns an action object used in signalling that the post editor settings have been updated. *Parameters* - * lockName: The lock name. \ No newline at end of file + * settings: Updated settings \ No newline at end of file diff --git a/docs/designers-developers/developers/data/data-core.md b/docs/designers-developers/developers/data/data-core.md index 2a98c3bb5d3974..9b01fe325c295b 100644 --- a/docs/designers-developers/developers/data/data-core.md +++ b/docs/designers-developers/developers/data/data-core.md @@ -136,17 +136,50 @@ get back from the oEmbed preview API. Is the preview for the URL an oEmbed link fallback. -### hasUploadPermissions +### hasUploadPermissions (deprecated) -Return Upload Permissions. +Returns whether the current user can upload media. + +Calling this may trigger an OPTIONS request to the REST API via the +`canUser()` resolver. + +https://developer.wordpress.org/rest-api/reference/ + +*Deprecated* + +Deprecated since 5.0. Callers should use the more generic `canUser()` selector instead of + `hasUploadPermissions()`, e.g. `canUser( 'create', 'media' )`. *Parameters* - * state: State tree. + * state: Data state. *Returns* -Upload Permissions. +Whether or not the user can upload media. Defaults to `true` if the OPTIONS + request is being made. + +### 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. + +https://developer.wordpress.org/rest-api/reference/ + +*Parameters* + + * state: Data state. + * action: Action to check. One of: 'create', 'read', 'update', 'delete'. + * resource: REST resource to check, e.g. 'media' or 'posts'. + * id: Optional ID of the rest resource to check. + +*Returns* + +Whether or not the user can perform the action, + or `undefined` if the OPTIONS request is still being made. ## Actions @@ -213,4 +246,14 @@ Returns an action object used in signalling that Upload permissions have been re *Parameters* - * hasUploadPermissions: Does the user have permission to upload files? \ No newline at end of file + * hasUploadPermissions: Does the user have permission to upload files? + +### receiveUserPermission + +Returns an action object used in signalling that the current user has +permission to perform an action on a REST resource. + +*Parameters* + + * key: A key that represents the action and REST resource. + * isAllowed: Whether or not the user can perform the action. \ No newline at end of file diff --git a/docs/designers-developers/developers/feature-flags.md b/docs/designers-developers/developers/feature-flags.md new file mode 100644 index 00000000000000..47a28d0fa42c39 --- /dev/null +++ b/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/docs/designers-developers/developers/filters/README.md b/docs/designers-developers/developers/filters/README.md index 9715e15885935c..c110d2ed3a5543 100644 --- a/docs/designers-developers/developers/filters/README.md +++ b/docs/designers-developers/developers/filters/README.md @@ -1,7 +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 Gutenberg, but they’re also used extensively by WordPress Core itself. +[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, Gutenberg 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. +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](https://github.com/WordPress/packages/tree/master/packages/hooks). +You can also learn more about both APIs: [PHP](https://codex.wordpress.org/Plugin_API/) and [JavaScript](/packages/tree/master/packages/hooks). diff --git a/docs/designers-developers/developers/filters/autocomplete-filters.md b/docs/designers-developers/developers/filters/autocomplete-filters.md index 2b9d60476de80a..1707080e867e8c 100644 --- a/docs/designers-developers/developers/filters/autocomplete-filters.md +++ b/docs/designers-developers/developers/filters/autocomplete-filters.md @@ -1,8 +1,8 @@ # Autocomplete -Gutenberg provides an `editor.Autocomplete.completers` filter for extending and overriding the list of autocompleters used by blocks. +The `editor.Autocomplete.completers` filter is for extending and overriding the list of autocompleters used by blocks. -The `Autocomplete` component found in `@wordpress/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/editor`. +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 diff --git a/docs/designers-developers/developers/filters/block-filters.md b/docs/designers-developers/developers/filters/block-filters.md index a8df36979dd1e0..99ba4cc0a0d49f 100644 --- a/docs/designers-developers/developers/filters/block-filters.md +++ b/docs/designers-developers/developers/filters/block-filters.md @@ -1,10 +1,10 @@ # Block Filters -To modify the behavior of existing blocks, Gutenberg exposes several APIs: +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. +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:_ @@ -17,17 +17,41 @@ wp.blocks.registerBlockStyle( 'core/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 make registered style variation to be active by default when a block is inserted. +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', 'fancy-quote' ); +wp.blocks.unregisterBlockStyle( 'core/quote', 'large' ); ``` -The above removes the variation named `fancy-quote` from the `core/quote` block. +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' ); +} ); +``` ### Filters @@ -89,7 +113,7 @@ wp.hooks.addFilter( ); ``` -_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. +_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` @@ -115,7 +139,7 @@ wp.hooks.addFilter( #### `blocks.switchToBlockType.transformedBlock` -Used to 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. +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` @@ -200,7 +224,7 @@ var withClientIdClassName = wp.compose.createHigherOrderComponent( function( Blo {}, props, { - classsName: "block-" + props.clientId, + className: "block-" + props.clientId, } ); @@ -237,8 +261,9 @@ Adding blocks is easy enough, removing them is as easy. Plugin or theme authors ```js // my-plugin.js - -wp.blocks.unregisterBlockType( 'core/verse' ); +wp.domReady( function() { + wp.blocks.unregisterBlockType( 'core/verse' ); +} ); ``` and load this script in the Editor @@ -251,7 +276,7 @@ function my_plugin_blacklist_blocks() { wp_enqueue_script( 'my-plugin-blacklist-blocks', plugins_url( 'my-plugin.js', __FILE__ ), - array( 'wp-blocks' ) + array( 'wp-blocks', 'wp-dom-ready', 'wp-edit-post' ) ); } add_action( 'enqueue_block_editor_assets', 'my_plugin_blacklist_blocks' ); @@ -322,9 +347,9 @@ function my_plugin_block_categories( $categories, $post ) { 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/). +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. +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 @@ -335,5 +360,5 @@ To set an SVG icon for the category shown in the previous example, add the follo var svgIcon = el( SVG, { width: 20, height: 20, viewBox: '0 0 20 20'}, circle); wp.blocks.updateCategory( 'my-category', { icon: svgIcon } ); } )(); -``` +``` diff --git a/docs/designers-developers/developers/filters/editor-filters.md b/docs/designers-developers/developers/filters/editor-filters.md index 1289b8241936ba..9421d7cd688019 100644 --- a/docs/designers-developers/developers/filters/editor-filters.md +++ b/docs/designers-developers/developers/filters/editor-filters.md @@ -1,10 +1,10 @@ # Editor Filters (Experimental) -To modify the behavior of the editor experience, Gutenberg exposes the following Filters: +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. +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:_ @@ -16,3 +16,17 @@ var withImageSize = function( size, mediaId, postId ) { 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 ); +``` + diff --git a/docs/designers-developers/developers/internationalization.md b/docs/designers-developers/developers/internationalization.md index 68cafea0e4257f..2ae1b8f8777f38 100644 --- a/docs/designers-developers/developers/internationalization.md +++ b/docs/designers-developers/developers/internationalization.md @@ -1,47 +1,232 @@ # Internationalization -This document aims to give an overview of the possibilities for both internationalization and localization when developing with Gutenberg. +## What is Internationalization? -## PHP +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_. -For years, WordPress has been providing the necessary tools and functions to internationalize plugins and themes. This includes helper functions like `__()` and similar. +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. -### Common Methods +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. -- `__( 'Hello World', 'my-text-domain' )`: Translate a certain string. -- `_x( 'Block', 'noun', 'my-text-domain' )`: Translate a certain string with some additional context. -- `_e( 'Hello World', 'my-text-domain' )`: Translate and print a certain string. -- `esc_html__( 'Hello World', 'my-text-domain' )`: Translate a certain string and escape it for safe use in HTML output. -- `esc_html_e( 'Hello World', 'my-text-domain' )`: Translate a certain string, escape it for safe use in HTML output, and print it. -- `_n( '%s Comment', '%s Comments', $number, 'my-text-domain' )`: Translate and retrieve the singular or plural form based on the supplied number. - Usually used in combination with `sprintf()` and `number_format_i18n()`. +## How to use i18n in JavaScript -## JavaScript +WordPress 5.0 introduced the wp-i18n JavaScript package that provides the functions needed to add translatable strings as you would in PHP. -Historically, `wp_localize_script()` has been used to put server-side PHP data into a properly-escaped native JavaScript object. +First, add **wp-i18n** as a dependency when registering your script: -The new editor introduces a new approach to translating strings for the editor through a new package called `@wordpress/i18n`. +```php + 'myguten-script', + ) ); +} +add_action( 'init', 'myguten_block_init' ); +``` -Depending on your developer workflow, you might want to use WP-CLI's `wp i18n make-pot` command or a build tool for Babel called `@wordpress/babel-plugin-makepot` to create the necessary translation file. The latter approach integrates with Babel to extract the I18N methods. +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`: -### Common methods in wp.i18n (may look similar) +```js +const { __ } = wp.i18n; +const el = wp.element.createElement; +const { registerBlockType } = wp.blocks; -- `setLocaleData( data: Object, domain: string )`: Creates a new I18N instance providing translation data for a domain. -- `__( '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. -- `sprintf()`: JavaScript port of the PHP function with the same name. +registerBlockType( 'myguten/simple', { + title: __('Simple Block', 'myguten'), + category: 'widgets', -### Loading Translations + edit: () => { + return el( + 'p', + { style: { color:'red'}, }, + __('Hello World', 'myguten') + ); + }, -WordPress 5.0 introduces a new function called `wp_set_script_translations( 'my-script-handle', 'my-text-domain' )` to load translation files for a given script handle. + save: () => { + return el( + 'p', + { style: { color:'red'}, }, + __('Hello World', 'myguten') + ); + } +}); +``` + +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" -You can learn more about it in [the JavaScript I18N dev note](https://make.wordpress.org/core/2018/11/09/new-javascript-i18n-support-in-wordpress/). +#. 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 the [po2json utility](https://github.com/mikeedwards/po2json) which you install using npm. It might be easiest to install globally using: `npm install -g po2json`. Once installed, use the following command to convert to JED format: + +``` +po2json myguten-eo.po myguten-eo.json -f jed +``` + +This will generate the JSON file `myguten-eo.json` which looks like: + +```json +{ + "domain": "messages", + "locale_data": { + "messages": { + "": { + "domain": "messages", + "lang": "eo" + }, + "Scratch Plugin": [ + "Scratch kromprogrameto" + ], + "Simple Block": [ + "Simpla bloko" + ], + "Hello World": [ + "Saltuon mundo" + ] + } + } +} +``` + + +### 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. -## More Resources - -- [WP-CLI I18N command to generate translation catalogues](https://github.com/wp-cli/i18n-command) -- [Plugin Developer Handbook](https://developer.wordpress.org/plugins/internationalization/) -- [Theme Developer Handbook](https://developer.wordpress.org/themes/internationalization/) diff --git a/docs/designers-developers/developers/packages.md b/docs/designers-developers/developers/packages.md index 7096f67115a28e..b5ef06c18782a2 100644 --- a/docs/designers-developers/developers/packages.md +++ b/docs/designers-developers/developers/packages.md @@ -1,8 +1,8 @@ # Packages -Gutenberg exposes a list of JavaScript packages and tools for WordPress development. +WordPress exposes a list of JavaScript packages and tools for WordPress development. -## Using the packages via WordPress global +## 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. @@ -22,19 +22,19 @@ const { PlainText } = wp.editor; ``` -## Using the packages via npm +## 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 editor module with npm: +Using the same `PlainText` example, you would install the block editor module with npm: ```bash -npm install @wordpress/editor --save +npm install @wordpress/block-editor --save ``` Once installed, you can access the component in your code using: ```js -import { PlainText } from '@wordpress/editor'; +import { PlainText } from '@wordpress/block-editor'; ``` diff --git a/docs/designers-developers/developers/themes/theme-support.md b/docs/designers-developers/developers/themes/theme-support.md index a5da2c7199e97b..57a2f748f641c8 100644 --- a/docs/designers-developers/developers/themes/theme-support.md +++ b/docs/designers-developers/developers/themes/theme-support.md @@ -5,7 +5,7 @@ The new Blocks include baseline support in all themes, enhancements to opt-in to 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 and register their own and optionally lock users into picking from preselected sizes. +- **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. @@ -137,28 +137,24 @@ Blocks may allow the user to configure the font sizes they use, e.g., the paragr ```php add_theme_support( 'editor-font-sizes', array( array( - 'name' => __( 'small', 'themeLangDomain' ), - 'shortName' => __( 'S', 'themeLangDomain' ), + 'name' => __( 'Small', 'themeLangDomain' ), 'size' => 12, 'slug' => 'small' ), array( - 'name' => __( 'regular', 'themeLangDomain' ), - 'shortName' => __( 'M', 'themeLangDomain' ), + 'name' => __( 'Normal', 'themeLangDomain' ), 'size' => 16, - 'slug' => 'regular' + 'slug' => 'normal' ), array( - 'name' => __( 'large', 'themeLangDomain' ), - 'shortName' => __( 'L', 'themeLangDomain' ), + 'name' => __( 'Large', 'themeLangDomain' ), 'size' => 36, 'slug' => 'large' ), array( - 'name' => __( 'larger', 'themeLangDomain' ), - 'shortName' => __( 'XL', 'themeLangDomain' ), + 'name' => __( 'Huge', 'themeLangDomain' ), 'size' => 50, - 'slug' => 'larger' + 'slug' => 'huge' ) ) ); ``` @@ -270,7 +266,7 @@ To change the main column width of the editor, add the following CSS to `style-e 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](https://wordpress.org/gutenberg/handbook/blocks/applying-styles-with-stylesheets/). +Further reading: [Applying Styles with Stylesheets](/docs/designers-developers/developers/tutorials/block-tutorial/applying-styles-with-stylesheets.md). ## Default block styles diff --git a/docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbars-and-inspector.md b/docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbars-and-inspector.md index de6509e4c42b73..1bad64b3d95329 100644 --- a/docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbars-and-inspector.md +++ b/docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbars-and-inspector.md @@ -4,7 +4,7 @@ To simplify block customization and ensure a consistent experience for users, th ## Toolbar -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 an RichText component. @@ -171,7 +171,7 @@ Note that `BlockControls` is only visible when the block is currently selected a ## Inspector -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 inspector is used to display less-often-used settings or settings that require more screen space. The inspector should be used for **block-level settings only**. diff --git a/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md b/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md index e905d0b1a9a941..aa785ff85e31f6 100644 --- a/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md +++ b/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md @@ -124,7 +124,7 @@ There are a few things to notice: ## Live rendering in Gutenberg editor -Gutenberg 2.8 added the [``](https://github.com/WordPress/gutenberg/tree/master/packages/components/src/server-side-render) block which enables rendering to take place on the server using PHP rather than in JavaScript. +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).* diff --git a/docs/designers-developers/developers/tutorials/block-tutorial/generate-blocks-with-wp-cli.md b/docs/designers-developers/developers/tutorials/block-tutorial/generate-blocks-with-wp-cli.md index 81c3fa0373a82b..b8fc6862dc69dc 100644 --- a/docs/designers-developers/developers/tutorials/block-tutorial/generate-blocks-with-wp-cli.md +++ b/docs/designers-developers/developers/tutorials/block-tutorial/generate-blocks-with-wp-cli.md @@ -5,7 +5,7 @@ It turns out that writing the simplest possible block which contains only static - [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](https://github.com/WordPress/gutenberg/tree/master/packages/block-library/src) to see how they are implemented. +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 @@ -62,7 +62,7 @@ This will generate 4 files inside the `movies` plugin directory. All files conta * Registers all block assets so that they can be enqueued through Gutenberg in * the corresponding context. * - * @see https://wordpress.org/gutenberg/handbook/blocks/writing-your-first-block-type/#enqueuing-block-scripts + * @see https://wordpress.org/gutenberg/handbook/designers-developers/developers/tutorials/block-tutorial/writing-your-first-block-type/ */ function movie_block_init() { $dir = dirname( __FILE__ ); @@ -109,23 +109,23 @@ add_action( 'init', 'movie_block_init' ); ( function( wp ) { /** * Registers a new block provided a unique name and an object defining its behavior. - * @see https://github.com/WordPress/gutenberg/tree/master/blocks#api + * @see https://wordpress.org/gutenberg/handbook/designers-developers/developers/block-api/#registering-a-block */ var registerBlockType = wp.blocks.registerBlockType; /** * Returns a new element of given type. Element is an abstraction layer atop React. - * @see https://github.com/WordPress/gutenberg/tree/master/packages/element#element + * @see https://wordpress.org/gutenberg/handbook/designers-developers/developers/packages/packages-element/ */ var el = wp.element.createElement; /** * Retrieves the translation of text. - * @see https://github.com/WordPress/gutenberg/tree/master/i18n#api + * @see https://wordpress.org/gutenberg/handbook/designers-developers/developers/packages/packages-i18n/ */ var __ = wp.i18n.__; /** * Every block starts by registering a new block type definition. - * @see https://wordpress.org/gutenberg/handbook/block-api/ + * @see https://wordpress.org/gutenberg/handbook/designers-developers/developers/block-api/#registering-a-block */ registerBlockType( 'movies/movie', { /** @@ -151,7 +151,7 @@ add_action( 'init', 'movie_block_init' ); /** * 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://wordpress.org/gutenberg/handbook/block-edit-save/#edit + * @see https://wordpress.org/gutenberg/handbook/designers-developers/developers/block-api/block-edit-save/#edit * * @param {Object} [props] Properties passed from the editor. * @return {Element} Element to render. @@ -167,7 +167,7 @@ add_action( 'init', 'movie_block_init' ); /** * 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://wordpress.org/gutenberg/handbook/block-edit-save/#save + * @see https://wordpress.org/gutenberg/handbook/designers-developers/developers/block-api/block-edit-save/#save * * @return {Element} Element to render. */ diff --git a/docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md b/docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md index 3a993ce1c2b71a..c51bd505bdd2e2 100644 --- a/docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md +++ b/docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md @@ -110,7 +110,7 @@ registerBlockType( 'gutenberg-boilerplate-esnext/hello-world-step-03', { ``` {% end %} -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. +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, we will extract the `content` value as the HTML of the paragraph element in the saved post's markup. @@ -118,7 +118,21 @@ In the code snippet above, when loading the editor, we will extract the `content Earlier examples used the `createElement` function to create DOM nodes, but it's also possible to encapsulate this behavior into ["components"](). This abstraction helps as a pattern to share common behaviors and to hide complexity into self-contained units. There are a number of components available to use in implementing your blocks. You can see one such component in the snippet above: the [`RichText` component](). -The `RichText` component can be considered as a super-powered `textarea` element, enabling rich content editing including bold, italics, hyperlinks, etc. It is not too much unlike the single editor region of the legacy post editor, and is in fact powered by the same TinyMCE library. +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 array of registered script handles when calling `wp_register_script`. + +```php +wp_register_script( + 'gutenberg-boilerplate-es5-step03', + plugins_url( 'step-03/block.js', __FILE__ ), + array( + 'wp-blocks', + 'wp-element', + 'wp-editor', // Note the addition of wp-editor to the dependencies + ) +); +``` 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. diff --git a/docs/designers-developers/developers/tutorials/block-tutorial/writing-your-first-block-type.md b/docs/designers-developers/developers/tutorials/block-tutorial/writing-your-first-block-type.md index e696b73e4e911d..ea9a18bbf23684 100644 --- a/docs/designers-developers/developers/tutorials/block-tutorial/writing-your-first-block-type.md +++ b/docs/designers-developers/developers/tutorials/block-tutorial/writing-your-first-block-type.md @@ -28,7 +28,7 @@ add_action( 'init', 'gutenberg_boilerplate_block' ); Note the two script dependencies: - __`wp-blocks`__ includes block type registration and related functions -- __`wp-element`__ includes the [WordPress Element abstraction](https://github.com/WordPress/gutenberg/tree/master/packages/element) for describing the structure of your blocks +- __`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. @@ -82,7 +82,7 @@ registerBlockType( 'gutenberg-boilerplate-esnext/hello-world-step-01', { ``` {% end %} -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](https://wordpress.org/gutenberg/handbook/block-api/#icon-optional). +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. diff --git a/docs/designers-developers/developers/tutorials/format-api/1-register-format.md b/docs/designers-developers/developers/tutorials/format-api/1-register-format.md new file mode 100644 index 00000000000000..6a18806f6a48a4 --- /dev/null +++ b/docs/designers-developers/developers/tutorials/format-api/1-register-format.md @@ -0,0 +1,48 @@ +# 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 +` 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: + +```js +( function( wp ) { + var MyCustomButton = function( props ) { + return wp.element.createElement( + wp.editor.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 ); +``` + +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 `` 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/docs/designers-developers/developers/tutorials/format-api/README.md b/docs/designers-developers/developers/tutorials/format-api/README.md new file mode 100644 index 00000000000000..3ecb76c07e0596 --- /dev/null +++ b/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 [`` 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/docs/designers-developers/developers/tutorials/javascript/extending-the-block-editor.md b/docs/designers-developers/developers/tutorials/javascript/extending-the-block-editor.md new file mode 100644 index 00000000000000..024b1f50e93676 --- /dev/null +++ b/docs/designers-developers/developers/tutorials/javascript/extending-the-block-editor.md @@ -0,0 +1,66 @@ +# 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 +
Hola, mundo!
, + save: () =>
Hola, mundo!
, +} ); +``` + +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: + +- Install dependencies: `npm install` +- Start development builds: `npm start` +- Develop. Test. Repeat. +- Create production build: `npm run build` diff --git a/docs/designers-developers/developers/tutorials/javascript/loading-javascript.md b/docs/designers-developers/developers/tutorials/javascript/loading-javascript.md new file mode 100644 index 00000000000000..f48775df5782d9 --- /dev/null +++ b/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/docs/designers-developers/developers/tutorials/javascript/plugins-background.md b/docs/designers-developers/developers/tutorials/javascript/plugins-background.md new file mode 100644 index 00000000000000..91a6ee302fe758 --- /dev/null +++ b/docs/designers-developers/developers/tutorials/javascript/plugins-background.md @@ -0,0 +1,16 @@ +# Plugins Background + +The primary means of extending WordPress is the plugin. WordPress' [Plugin Basics](https://developer.wordpress.org/plugins/the-basics/) documentation provides for more details on building a plugin. The quickest way to start is to create a new directory in `wp-content/plugins/` to hold your plugin code, for this example you can call it `myguten-plugin`. + +Inside of this new directory, create a file called `myguten-plugin.php` which is the server-side code that runs when your plugin is active. For now place the following in that file: + +```php + 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/docs/designers-developers/developers/tutorials/javascript/troubleshooting.md b/docs/designers-developers/developers/tutorials/javascript/troubleshooting.md new file mode 100644 index 00000000000000..c5b6d9895c91b5 --- /dev/null +++ b/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 ` - is_block_editor(); } - -/** - * Adds attributes to kses allowed tags that aren't in the default list - * and that Gutenberg needs to save blocks such as the Gallery block. - * - * @param array $tags Allowed HTML. - * @return array (Maybe) modified allowed HTML. - */ -function gutenberg_kses_allowedtags( $tags ) { - if ( isset( $tags['img'] ) ) { - $tags['img']['data-link'] = true; - $tags['img']['data-id'] = true; - } - return $tags; -} - -add_filter( 'wp_kses_allowed_html', 'gutenberg_kses_allowedtags', 10, 2 ); - -/** - * Adds the wp-embed-responsive class to the body tag if the theme has opted in to - * Gutenberg responsive embeds. - * - * @since 4.1.0 - * - * @param Array $classes Array of classes being added to the body tag. - * @return Array The $classes array, with wp-embed-responsive appended. - */ -function gutenberg_add_responsive_body_class( $classes ) { - if ( current_theme_supports( 'responsive-embeds' ) ) { - $classes[] = 'wp-embed-responsive'; - } - return $classes; -} - -add_filter( 'body_class', 'gutenberg_add_responsive_body_class' ); diff --git a/jsconfig.json b/jsconfig.json index 7e800a9c053b43..51f5ce09b49e00 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -9,7 +9,7 @@ "build", "build-module", "node_modules", - "test/e2e/test-plugins", + "packages/e2e-tests/plugins", "vendor" ] } diff --git a/languages/README.md b/languages/README.md deleted file mode 100644 index 3f3ba1d4478c68..00000000000000 --- a/languages/README.md +++ /dev/null @@ -1,10 +0,0 @@ -Languages -========= - -The generated POT template file is not included in this repository. To create this file locally, follow instructions from [CONTRIBUTING.md](https://github.com/WordPress/gutenberg/blob/master/CONTRIBUTING.md) to install the project, then run the following command: - -``` -npm run build -``` - -After the build completes, you'll find a `gutenberg.pot` strings file in this directory. diff --git a/lib/blocks.php b/lib/blocks.php deleted file mode 100644 index 0b11041d0576de..00000000000000 --- a/lib/blocks.php +++ /dev/null @@ -1,271 +0,0 @@ -register( $name, $args ); - } -} - -if ( ! function_exists( 'unregister_block_type' ) ) { - /** - * Unregisters a block type. - * - * @since 0.1.0 - * @since 0.6.0 Now also accepts a WP_Block_Type instance as first parameter. - * - * @param string|WP_Block_Type $name Block type name including namespace, or alternatively a - * complete WP_Block_Type instance. - * @return WP_Block_Type|false The unregistered block type on success, or false on failure. - */ - function unregister_block_type( $name ) { - return WP_Block_Type_Registry::get_instance()->unregister( $name ); - } -} - -if ( ! function_exists( 'gutenberg_parse_blocks' ) ) { - /** - * Parses blocks out of a content string. - * - * @since 0.5.0 - * - * @param string $content Post content. - * @return array Array of parsed block objects. - */ - function gutenberg_parse_blocks( $content ) { - /** - * Filter to allow plugins to replace the server-side block parser - * - * @since 3.8.0 - * - * @param string $parser_class Name of block parser class - */ - $parser_class = apply_filters( 'block_parser_class', 'WP_Block_Parser' ); - // Load default block parser for server-side parsing if the default parser class is being used. - if ( 'WP_Block_Parser' === $parser_class ) { - require_once dirname( __FILE__ ) . '/../packages/block-serialization-default-parser/parser.php'; - } - $parser = new $parser_class(); - return $parser->parse( $content ); - } -} - -if ( ! function_exists( 'get_dynamic_block_names' ) ) { - /** - * Returns an array of the names of all registered dynamic block types. - * - * @return array Array of dynamic block names. - */ - function get_dynamic_block_names() { - $dynamic_block_names = array(); - - $block_types = WP_Block_Type_Registry::get_instance()->get_all_registered(); - foreach ( $block_types as $block_type ) { - if ( $block_type->is_dynamic() ) { - $dynamic_block_names[] = $block_type->name; - } - } - - return $dynamic_block_names; - } -} - -if ( ! function_exists( 'get_dynamic_blocks_regex' ) ) { - /** - * Retrieve the dynamic blocks regular expression for searching. - * - * @since 3.6.0 - * - * @return string - */ - function get_dynamic_blocks_regex() { - $dynamic_block_names = get_dynamic_block_names(); - $dynamic_block_pattern = ( - '//' - ); - - return $dynamic_block_pattern; - } -} - -/** - * Renders a single block into a HTML string. - * - * @since 1.9.0 - * @since 4.4.0 renders full nested tree of blocks before reassembling into HTML string - * @global WP_Post $post The post to edit. - * - * @param array $block A single parsed block object. - * @return string String of rendered HTML. - */ -function gutenberg_render_block( $block ) { - global $post; - - $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); - $is_dynamic = $block['blockName'] && null !== $block_type && $block_type->is_dynamic(); - $inner_content = ''; - $index = 0; - - foreach ( $block['innerContent'] as $chunk ) { - $inner_content .= is_string( $chunk ) ? $chunk : gutenberg_render_block( $block['innerBlocks'][ $index++ ] ); - } - - if ( $is_dynamic ) { - $attributes = is_array( $block['attrs'] ) ? (array) $block['attrs'] : array(); - $global_post = $post; - $output = $block_type->render( $attributes, $inner_content ); - $post = $global_post; - - return $output; - } - - return $inner_content; -} - -if ( ! function_exists( 'do_blocks' ) ) { - /** - * Parses dynamic blocks out of `post_content` and re-renders them. - * - * @since 0.1.0 - * @since 4.4.0 performs full parse on input post content - * - * @param string $content Post content. - * @return string Updated post content. - */ - function do_blocks( $content ) { - // If there are blocks in this content, we shouldn't run wpautop() on it later. - $priority = has_filter( 'the_content', 'wpautop' ); - if ( false !== $priority && doing_filter( 'the_content' ) && has_blocks( $content ) ) { - remove_filter( 'the_content', 'wpautop', $priority ); - add_filter( 'the_content', '_restore_wpautop_hook', $priority + 1 ); - } - - $blocks = gutenberg_parse_blocks( $content ); - $output = ''; - - foreach ( $blocks as $block ) { - $output .= gutenberg_render_block( $block ); - } - - return $output; - } - - add_filter( 'the_content', 'do_blocks', 7 ); // BEFORE do_shortcode() and oembed. -} - -if ( ! function_exists( '_restore_wpautop_hook' ) ) { - /** - * If do_blocks() needs to remove wpautop() from the `the_content` filter, - * this re-adds it afterwards, for subsequent `the_content` usage. - * - * @access private - * - * @since 4.6.0 - * - * @param string $content The post content running through this filter. - * @return string The unmodified content. - */ - function _restore_wpautop_hook( $content ) { - $current_priority = has_filter( 'the_content', '_restore_wpautop_hook' ); - - add_filter( 'the_content', 'wpautop', $current_priority - 1 ); - remove_filter( 'the_content', '_restore_wpautop_hook', $current_priority ); - - return $content; - } -} - -if ( ! function_exists( 'strip_dynamic_blocks' ) ) { - /** - * Remove all dynamic blocks from the given content. - * - * @since 3.6.0 - * - * @param string $content Content of the current post. - * @return string - */ - function strip_dynamic_blocks( $content ) { - return preg_replace( get_dynamic_blocks_regex(), '', $content ); - } -} - -if ( ! function_exists( 'strip_dynamic_blocks_add_filter' ) ) { - /** - * Adds the content filter to strip dynamic blocks from excerpts. - * - * It's a bit hacky for now, but once this gets merged into core the function - * can just be called in `wp_trim_excerpt()`. - * - * @since 3.6.0 - * - * @param string $text Excerpt. - * @return string - */ - function strip_dynamic_blocks_add_filter( $text ) { - add_filter( 'the_content', 'strip_dynamic_blocks', 6 ); - - return $text; - } - add_filter( 'get_the_excerpt', 'strip_dynamic_blocks_add_filter', 9 ); // Before wp_trim_excerpt(). -} - -if ( ! function_exists( 'strip_dynamic_blocks_remove_filter' ) ) { - /** - * Removes the content filter to strip dynamic blocks from excerpts. - * - * It's a bit hacky for now, but once this gets merged into core the function - * can just be called in `wp_trim_excerpt()`. - * - * @since 3.6.0 - * - * @param string $text Excerpt. - * @return string - */ - function strip_dynamic_blocks_remove_filter( $text ) { - remove_filter( 'the_content', 'strip_dynamic_blocks', 6 ); - - return $text; - } - add_filter( 'wp_trim_excerpt', 'strip_dynamic_blocks_remove_filter', 0 ); // Before all other. -} diff --git a/lib/class-wp-block-type-registry.php b/lib/class-wp-block-type-registry.php deleted file mode 100644 index f95dec6ce4b9d1..00000000000000 --- a/lib/class-wp-block-type-registry.php +++ /dev/null @@ -1,182 +0,0 @@ - $instance` pairs. - * - * @since 0.6.0 - * @access private - * @var WP_Block_Type[] - */ - private $registered_block_types = array(); - - /** - * Container for the main instance of the class. - * - * @since 0.6.0 - * @access private - * @static - * @var WP_Block_Type_Registry|null - */ - private static $instance = null; - - /** - * Registers a block type. - * - * @since 0.6.0 - * @access public - * - * @param string|WP_Block_Type $name Block type name including namespace, or alternatively a - * complete WP_Block_Type instance. In case a WP_Block_Type - * is provided, the $args parameter will be ignored. - * @param array $args { - * Optional. Array of block type arguments. Any arguments may be defined, however the - * ones described below are supported by default. Default empty array. - * - * @type callable $render_callback Callback used to render blocks of this block type. - * @type array $attributes Block attributes mapping, property name to schema. - * } - * @return WP_Block_Type|false The registered block type on success, or false on failure. - */ - public function register( $name, $args = array() ) { - $block_type = null; - if ( $name instanceof WP_Block_Type ) { - $block_type = $name; - $name = $block_type->name; - } - - if ( ! is_string( $name ) ) { - $message = __( 'Block type names must be strings.', 'gutenberg' ); - _doing_it_wrong( __METHOD__, $message, '0.1.0' ); - return false; - } - - if ( preg_match( '/[A-Z]+/', $name ) ) { - $message = __( 'Block type names must not contain uppercase characters.', 'gutenberg' ); - _doing_it_wrong( __METHOD__, $message, '1.5.0' ); - return false; - } - - $name_matcher = '/^[a-z0-9-]+\/[a-z0-9-]+$/'; - if ( ! preg_match( $name_matcher, $name ) ) { - $message = __( 'Block type names must contain a namespace prefix. Example: my-plugin/my-custom-block-type', 'gutenberg' ); - _doing_it_wrong( __METHOD__, $message, '0.1.0' ); - return false; - } - - if ( $this->is_registered( $name ) ) { - /* translators: 1: block name */ - $message = sprintf( __( 'Block type "%s" is already registered.', 'gutenberg' ), $name ); - _doing_it_wrong( __METHOD__, $message, '0.1.0' ); - return false; - } - - if ( ! $block_type ) { - $block_type = new WP_Block_Type( $name, $args ); - } - - $this->registered_block_types[ $name ] = $block_type; - - return $block_type; - } - - /** - * Unregisters a block type. - * - * @since 0.6.0 - * @access public - * - * @param string|WP_Block_Type $name Block type name including namespace, or alternatively a - * complete WP_Block_Type instance. - * @return WP_Block_Type|false The unregistered block type on success, or false on failure. - */ - public function unregister( $name ) { - if ( $name instanceof WP_Block_Type ) { - $name = $name->name; - } - - if ( ! $this->is_registered( $name ) ) { - /* translators: 1: block name */ - $message = sprintf( __( 'Block type "%s" is not registered.', 'gutenberg' ), $name ); - _doing_it_wrong( __METHOD__, $message, '0.1.0' ); - return false; - } - - $unregistered_block_type = $this->registered_block_types[ $name ]; - unset( $this->registered_block_types[ $name ] ); - - return $unregistered_block_type; - } - - /** - * Retrieves a registered block type. - * - * @since 0.6.0 - * @access public - * - * @param string $name Block type name including namespace. - * @return WP_Block_Type|null The registered block type, or null if it is not registered. - */ - public function get_registered( $name ) { - if ( ! $this->is_registered( $name ) ) { - return null; - } - - return $this->registered_block_types[ $name ]; - } - - /** - * Retrieves all registered block types. - * - * @since 0.6.0 - * @access public - * - * @return WP_Block_Type[] Associative array of `$block_type_name => $block_type` pairs. - */ - public function get_all_registered() { - return $this->registered_block_types; - } - - /** - * Checks if a block type is registered. - * - * @since 0.6.0 - * @access public - * - * @param string $name Block type name including namespace. - * @return bool True if the block type is registered, false otherwise. - */ - public function is_registered( $name ) { - return isset( $this->registered_block_types[ $name ] ); - } - - /** - * Utility method to retrieve the main instance of the class. - * - * The instance will be created if it does not exist yet. - * - * @since 0.6.0 - * @access public - * @static - * - * @return WP_Block_Type_Registry The main instance. - */ - public static function get_instance() { - if ( null === self::$instance ) { - self::$instance = new self(); - } - - return self::$instance; - } -} diff --git a/lib/class-wp-block-type.php b/lib/class-wp-block-type.php deleted file mode 100644 index c186eec88a0a3b..00000000000000 --- a/lib/class-wp-block-type.php +++ /dev/null @@ -1,209 +0,0 @@ -name = $block_type; - - $this->set_props( $args ); - } - - /** - * Renders the block type output for given attributes. - * - * @since 0.6.0 - * - * @param array $attributes Optional. Block attributes. Default empty array. - * @param string $content Optional. Block content. Default empty string. - * @return string Rendered block type output. - */ - public function render( $attributes = array(), $content = '' ) { - if ( ! $this->is_dynamic() ) { - return ''; - } - - $attributes = $this->prepare_attributes_for_render( $attributes ); - - return (string) call_user_func( $this->render_callback, $attributes, $content ); - } - - /** - * Returns true if the block type is dynamic, or false otherwise. A dynamic - * block is one which defers its rendering to occur on-demand at runtime. - * - * @return boolean Whether block type is dynamic. - */ - public function is_dynamic() { - return is_callable( $this->render_callback ); - } - - /** - * Validates attributes against the current block schema, populating - * defaulted and missing values. - * - * @param array $attributes Original block attributes. - * @return array Prepared block attributes. - */ - public function prepare_attributes_for_render( $attributes ) { - // If there are no attribute definitions for the block type, skip - // processing and return vebatim. - if ( ! isset( $this->attributes ) ) { - return $attributes; - } - - foreach ( $attributes as $attribute_name => $value ) { - // If the attribute is not defined by the block type, it cannot be - // validated. - if ( ! isset( $this->attributes[ $attribute_name ] ) ) { - continue; - } - - $schema = $this->attributes[ $attribute_name ]; - - // Validate value by JSON schema. An invalid value should revert to - // its default, if one exists. This occurs by virtue of the missing - // attributes loop immediately following. If there is not a default - // assigned, the attribute value should remain unset. - $is_valid = rest_validate_value_from_schema( $value, $schema ); - if ( is_wp_error( $is_valid ) ) { - unset( $attributes[ $attribute_name ] ); - } - } - - // Populate values of any missing attributes for which the block type - // defines a default. - $missing_schema_attributes = array_diff_key( $this->attributes, $attributes ); - foreach ( $missing_schema_attributes as $attribute_name => $schema ) { - if ( isset( $schema['default'] ) ) { - $attributes[ $attribute_name ] = $schema['default']; - } - } - - return $attributes; - } - - /** - * Sets block type properties. - * - * @since 0.6.0 - * - * @param array|string $args Array or string of arguments for registering a block type. - */ - public function set_props( $args ) { - $args = wp_parse_args( - $args, - array( - 'render_callback' => null, - ) - ); - - $args['name'] = $this->name; - - foreach ( $args as $property_name => $property_value ) { - $this->$property_name = $property_value; - } - } - - /** - * Get all available block attributes including possible layout attribute from Columns block. - * - * @return array Array of attributes. - */ - public function get_attributes() { - return is_array( $this->attributes ) ? - array_merge( - $this->attributes, - array( - 'layout' => array( - 'type' => 'string', - ), - ) - ) : - array( - 'layout' => array( - 'type' => 'string', - ), - ); - } -} diff --git a/lib/class-wp-rest-autosaves-controller.php b/lib/class-wp-rest-autosaves-controller.php deleted file mode 100644 index 6f6496c35790e3..00000000000000 --- a/lib/class-wp-rest-autosaves-controller.php +++ /dev/null @@ -1,426 +0,0 @@ -parent_post_type = $parent_post_type; - $post_type_object = get_post_type_object( $parent_post_type ); - - // Ensure that post type-specific controller logic is available. - $parent_controller_class = ! empty( $post_type_object->rest_controller_class ) ? $post_type_object->rest_controller_class : 'WP_REST_Posts_Controller'; - - $this->parent_controller = new $parent_controller_class( $post_type_object->name ); - $this->revisions_controller = new WP_REST_Revisions_Controller( $parent_post_type ); - $this->rest_namespace = 'wp/v2'; - $this->rest_base = 'autosaves'; - $this->parent_base = ! empty( $post_type_object->rest_base ) ? $post_type_object->rest_base : $post_type_object->name; - } - - /** - * Registers routes for autosaves. - * - * @since 5.0.0 - * - * @see register_rest_route() - */ - public function register_routes() { - register_rest_route( - $this->rest_namespace, - '/' . $this->parent_base . '/(?P[\d]+)/' . $this->rest_base, - array( - 'args' => array( - 'parent' => array( - 'description' => __( 'The ID for the parent of the object.', 'gutenberg' ), - 'type' => 'integer', - ), - ), - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_items' ), - 'permission_callback' => array( $this, 'get_items_permissions_check' ), - 'args' => $this->get_collection_params(), - ), - array( - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'create_item' ), - 'permission_callback' => array( $this, 'create_item_permissions_check' ), - 'args' => $this->parent_controller->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), - ), - 'schema' => array( $this, 'get_public_item_schema' ), - ) - ); - - register_rest_route( - $this->rest_namespace, - '/' . $this->parent_base . '/(?P[\d]+)/' . $this->rest_base . '/(?P[\d]+)', - array( - 'args' => array( - 'parent' => array( - 'description' => __( 'The ID for the parent of the object.', 'gutenberg' ), - 'type' => 'integer', - ), - 'id' => array( - 'description' => __( 'The ID for the object.', 'gutenberg' ), - 'type' => 'integer', - ), - ), - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_item' ), - 'permission_callback' => array( $this->revisions_controller, 'get_item_permissions_check' ), - 'args' => array( - 'context' => $this->get_context_param( array( 'default' => 'view' ) ), - ), - ), - 'schema' => array( $this, 'get_public_item_schema' ), - ) - ); - - } - - /** - * Get the parent post. - * - * @since 5.0.0 - * - * @param int $parent_id Supplied ID. - * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise. - */ - protected function get_parent( $parent_id ) { - return $this->revisions_controller->get_parent( $parent_id ); - } - - /** - * Checks if a given request has access to get autosaves. - * - * @since 5.0.0 - * - * @param WP_REST_Request $request Full data about the request. - * @return true|WP_Error True if the request has read access, WP_Error object otherwise. - */ - public function get_items_permissions_check( $request ) { - $parent = $this->get_parent( $request['id'] ); - if ( is_wp_error( $parent ) ) { - return $parent; - } - - $parent_post_type_obj = get_post_type_object( $parent->post_type ); - if ( ! current_user_can( $parent_post_type_obj->cap->edit_post, $parent->ID ) ) { - return new WP_Error( 'rest_cannot_read', __( 'Sorry, you are not allowed to view revisions of this post.', 'gutenberg' ), array( 'status' => rest_authorization_required_code() ) ); - } - - return true; - } - - /** - * Checks if a given request has access to create an autosave revision. - * - * Autosave revisions inherit permissions from the parent post, - * check if the current user has permission to edit the post. - * - * @since 5.0.0 - * - * @param WP_REST_Request $request Full details about the request. - * @return true|WP_Error True if the request has access to create the item, WP_Error object otherwise. - */ - public function create_item_permissions_check( $request ) { - $id = $request->get_param( 'id' ); - if ( empty( $id ) ) { - return new WP_Error( 'rest_post_invalid_id', __( 'Invalid item ID.', 'gutenberg' ), array( 'status' => 404 ) ); - } - - return $this->parent_controller->update_item_permissions_check( $request ); - } - - /** - * Creates, updates or deletes an autosave revision. - * - * @since 5.0.0 - * - * @param WP_REST_Request $request Full details about the request. - * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. - */ - public function create_item( $request ) { - - if ( ! defined( 'DOING_AUTOSAVE' ) ) { - define( 'DOING_AUTOSAVE', true ); - } - - $post = get_post( $request['id'] ); - - if ( is_wp_error( $post ) ) { - return $post; - } - - $prepared_post = $this->parent_controller->prepare_item_for_database( $request ); - $prepared_post->ID = $post->ID; - $user_id = get_current_user_id(); - - if ( ( 'draft' === $post->post_status || 'auto-draft' === $post->post_status ) && $post->post_author == $user_id ) { - // Draft posts for the same author: autosaving updates the post and does not create a revision. - // Convert the post object to an array and add slashes, wp_update_post expects escaped array. - $autosave_id = wp_update_post( wp_slash( (array) $prepared_post ), true ); - } else { - // Non-draft posts: create or update the post autosave. - $autosave_id = $this->create_post_autosave( (array) $prepared_post ); - } - - if ( is_wp_error( $autosave_id ) ) { - return $autosave_id; - } - - $autosave = get_post( $autosave_id ); - $request->set_param( 'context', 'edit' ); - - $response = $this->prepare_item_for_response( $autosave, $request ); - $response = rest_ensure_response( $response ); - - return $response; - } - - /** - * Get the autosave, if the ID is valid. - * - * @since 5.0.0 - * - * @param WP_REST_Request $request Full data about the request. - * @return WP_Post|WP_Error Revision post object if ID is valid, WP_Error otherwise. - */ - public function get_item( $request ) { - $parent_id = (int) $request->get_param( 'parent' ); - - if ( $parent_id <= 0 ) { - return new WP_Error( 'rest_post_invalid_id', __( 'Invalid parent post ID.', 'gutenberg' ), array( 'status' => 404 ) ); - } - - $autosave = wp_get_post_autosave( $parent_id ); - - if ( ! $autosave ) { - return new WP_Error( 'rest_post_no_autosave', __( 'There is no autosave revision for this post.', 'gutenberg' ), array( 'status' => 404 ) ); - } - - $response = $this->prepare_item_for_response( $autosave, $request ); - return $response; - } - - /** - * Gets a collection of autosaves using wp_get_post_autosave. - * - * Contains the user's autosave, for empty if it doesn't exist. - * - * @since 5.0.0 - * - * @param WP_REST_Request $request Full data about the request. - * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. - */ - public function get_items( $request ) { - $parent = $this->get_parent( $request['id'] ); - if ( is_wp_error( $parent ) ) { - return $parent; - } - - $response = array(); - $parent_id = $parent->ID; - $revisions = wp_get_post_revisions( $parent_id, array( 'check_enabled' => false ) ); - - foreach ( $revisions as $revision ) { - if ( false !== strpos( $revision->post_name, "{$parent_id}-autosave" ) ) { - $data = $this->prepare_item_for_response( $revision, $request ); - $response[] = $this->prepare_response_for_collection( $data ); - } - } - - return rest_ensure_response( $response ); - } - - - /** - * Retrieves the autosave's schema, conforming to JSON Schema. - * - * @since 5.0.0 - * - * @return array Item schema data. - */ - public function get_item_schema() { - $schema = $this->revisions_controller->get_item_schema(); - - $schema['properties']['preview_link'] = array( - 'description' => __( 'Preview link for the post.', 'gutenberg' ), - 'type' => 'string', - 'format' => 'uri', - 'context' => array( 'edit' ), - 'readonly' => true, - ); - - return $schema; - } - - /** - * Creates autosave for the specified post. - * - * From wp-admin/post.php. - * - * @since 5.0.0 - * - * @param mixed $post_data Associative array containing the post data. - * @return mixed The autosave revision ID or WP_Error. - */ - public function create_post_autosave( $post_data ) { - - $post_id = (int) $post_data['ID']; - $post = get_post( $post_id ); - - if ( is_wp_error( $post ) ) { - return $post; - } - - $user_id = get_current_user_id(); - - // Store one autosave per author. If there is already an autosave, overwrite it. - $old_autosave = wp_get_post_autosave( $post_id, $user_id ); - - if ( $old_autosave ) { - $new_autosave = _wp_post_revision_data( $post_data, true ); - $new_autosave['ID'] = $old_autosave->ID; - $new_autosave['post_author'] = $user_id; - - // If the new autosave has the same content as the post, delete the autosave. - $autosave_is_different = false; - - foreach ( array_intersect( array_keys( $new_autosave ), array_keys( _wp_post_revision_fields( $post ) ) ) as $field ) { - if ( normalize_whitespace( $new_autosave[ $field ] ) != normalize_whitespace( $post->$field ) ) { - $autosave_is_different = true; - break; - } - } - - if ( ! $autosave_is_different ) { - wp_delete_post_revision( $old_autosave->ID ); - return new WP_Error( 'rest_autosave_no_changes', __( 'There is nothing to save. The autosave and the post content are the same.', 'gutenberg' ), array( 'status' => 400 ) ); - } - - /** - * This filter is documented in wp-admin/post.php. - */ - do_action( 'wp_creating_autosave', $new_autosave ); - - // wp_update_post expects escaped array. - return wp_update_post( wp_slash( $new_autosave ) ); - } - - // Create the new autosave as a special post revision. - return _wp_put_post_revision( $post_data, true ); - } - - /** - * Prepares the revision for the REST response. - * - * @since 5.0.0 - * - * @param WP_Post $post Post revision object. - * @param WP_REST_Request $request Request object. - * - * @return WP_REST_Response Response object. - */ - public function prepare_item_for_response( $post, $request ) { - - $response = $this->revisions_controller->prepare_item_for_response( $post, $request ); - - $schema = $this->get_item_schema(); - - if ( ! empty( $schema['properties']['preview_link'] ) ) { - $parent_id = wp_is_post_autosave( $post ); - $preview_post_id = false === $parent_id ? $post->ID : $parent_id; - $preview_query_args = array(); - - if ( false !== $parent_id ) { - $preview_query_args['preview_id'] = $parent_id; - $preview_query_args['preview_nonce'] = wp_create_nonce( 'post_preview_' . $parent_id ); - } - - $response->data['preview_link'] = get_preview_post_link( $preview_post_id, $preview_query_args ); - } - - $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; - $response->data = $this->filter_response_by_context( $response->data, $context ); - - /** - * Filters a revision returned from the API. - * - * Allows modification of the revision right before it is returned. - * - * @since 5.0.0 - * - * @param WP_REST_Response $response The response object. - * @param WP_Post $post The original revision object. - * @param WP_REST_Request $request Request used to generate the response. - */ - return apply_filters( 'rest_prepare_autosave', $response, $post, $request ); - } - - /** - * Retrieves the query params for the autosaves collection. - * - * @since 5.0.0 - * - * @return array Collection parameters. - */ - public function get_collection_params() { - return array( - 'context' => $this->get_context_param( array( 'default' => 'view' ) ), - ); - } -} diff --git a/lib/class-wp-rest-block-renderer-controller.php b/lib/class-wp-rest-block-renderer-controller.php deleted file mode 100644 index b9839f080376a2..00000000000000 --- a/lib/class-wp-rest-block-renderer-controller.php +++ /dev/null @@ -1,178 +0,0 @@ -namespace = 'wp/v2'; - $this->rest_base = 'block-renderer'; - } - - /** - * Registers the necessary REST API routes, one for each dynamic block. - * - * @access public - */ - public function register_routes() { - $block_types = WP_Block_Type_Registry::get_instance()->get_all_registered(); - foreach ( $block_types as $block_type ) { - if ( ! $block_type->is_dynamic() ) { - continue; - } - - register_rest_route( - $this->namespace, - '/' . $this->rest_base . '/(?P' . $block_type->name . ')', - array( - 'args' => array( - 'name' => array( - 'description' => __( 'Unique registered name for the block.', 'gutenberg' ), - 'type' => 'string', - ), - ), - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_item' ), - 'permission_callback' => array( $this, 'get_item_permissions_check' ), - 'args' => array( - 'context' => $this->get_context_param( array( 'default' => 'view' ) ), - 'attributes' => array( - /* translators: %s is the name of the block */ - 'description' => sprintf( __( 'Attributes for %s block', 'gutenberg' ), $block_type->name ), - 'type' => 'object', - 'additionalProperties' => false, - 'properties' => $block_type->get_attributes(), - 'default' => array(), - ), - 'post_id' => array( - 'description' => __( 'ID of the post context.', 'gutenberg' ), - 'type' => 'integer', - ), - ), - ), - 'schema' => array( $this, 'get_public_item_schema' ), - ) - ); - } - } - - /** - * Checks if a given request has access to read blocks. - * - * @since 2.8.0 - * @access public - * - * @param WP_REST_Request $request Request. - * @return true|WP_Error True if the request has read access, WP_Error object otherwise. - */ - public function get_item_permissions_check( $request ) { - global $post; - - $post_id = isset( $request['post_id'] ) ? intval( $request['post_id'] ) : 0; - - if ( 0 < $post_id ) { - $post = get_post( $post_id ); - if ( ! $post || ! current_user_can( 'edit_post', $post->ID ) ) { - return new WP_Error( - 'gutenberg_block_cannot_read', - __( 'Sorry, you are not allowed to read Gutenberg blocks of this post.', 'gutenberg' ), - array( - 'status' => rest_authorization_required_code(), - ) - ); - } - } else { - if ( ! current_user_can( 'edit_posts' ) ) { - return new WP_Error( - 'gutenberg_block_cannot_read', - __( 'Sorry, you are not allowed to read Gutenberg blocks as this user.', 'gutenberg' ), - array( - 'status' => rest_authorization_required_code(), - ) - ); - } - } - - return true; - } - - /** - * Returns block output from block's registered render_callback. - * - * @since 2.8.0 - * @access public - * - * @param WP_REST_Request $request Full details about the request. - * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. - */ - public function get_item( $request ) { - global $post; - - $post_id = isset( $request['post_id'] ) ? intval( $request['post_id'] ) : 0; - - if ( 0 < $post_id ) { - $post = get_post( $post_id ); - - // Set up postdata since this will be needed if post_id was set. - setup_postdata( $post ); - } - $registry = WP_Block_Type_Registry::get_instance(); - $block = $registry->get_registered( $request['name'] ); - - if ( null === $block ) { - return new WP_Error( - 'gutenberg_block_invalid', - __( 'Invalid block.', 'gutenberg' ), - array( - 'status' => 404, - ) - ); - } - - $data = array( - 'rendered' => $block->render( $request->get_param( 'attributes' ) ), - ); - return rest_ensure_response( $data ); - } - - /** - * Retrieves block's output schema, conforming to JSON Schema. - * - * @since 2.8.0 - * @access public - * - * @return array Item schema data. - */ - public function get_item_schema() { - return array( - '$schema' => 'http://json-schema.org/schema#', - 'title' => 'rendered-block', - 'type' => 'object', - 'properties' => array( - 'rendered' => array( - 'description' => __( 'The rendered block.', 'gutenberg' ), - 'type' => 'string', - 'required' => true, - 'context' => array( 'edit' ), - ), - ), - ); - } -} diff --git a/lib/class-wp-rest-blocks-controller.php b/lib/class-wp-rest-blocks-controller.php deleted file mode 100644 index 47882fafbff042..00000000000000 --- a/lib/class-wp-rest-blocks-controller.php +++ /dev/null @@ -1,89 +0,0 @@ -post_type ); - if ( ! current_user_can( $post_type->cap->read_post, $post->ID ) ) { - return false; - } - - return parent::check_read_permission( $post ); - } - - /** - * Filters a response based on the context defined in the schema. - * - * @since 4.4.0 - * - * @param array $data Response data to fiter. - * @param string $context Context defined in the schema. - * @return array Filtered response. - */ - public function filter_response_by_context( $data, $context ) { - $data = parent::filter_response_by_context( $data, $context ); - - /* - * Remove `title.rendered` and `content.rendered` from the response. It - * doesn't make sense for a reusable block to have rendered content on its - * own, since rendering a block requires it to be inside a post or a page. - */ - unset( $data['title']['rendered'] ); - unset( $data['content']['rendered'] ); - - return $data; - } - - /** - * Retrieves the block's schema, conforming to JSON Schema. - * - * @since 4.4.0 - * - * @return array Item schema data. - */ - public function get_item_schema() { - $schema = parent::get_item_schema(); - - /* - * Allow all contexts to access `title.raw` and `content.raw`. Clients always - * need the raw markup of a reusable block to do anything useful, e.g. parse - * it or display it in an editor. - */ - $schema['properties']['title']['properties']['raw']['context'] = array( 'view', 'edit' ); - $schema['properties']['content']['properties']['raw']['context'] = array( 'view', 'edit' ); - - /* - * Remove `title.rendered` and `content.rendered` from the schema. It doesn’t - * make sense for a reusable block to have rendered content on its own, since - * rendering a block requires it to be inside a post or a page. - */ - unset( $schema['properties']['title']['properties']['rendered'] ); - unset( $schema['properties']['content']['properties']['rendered'] ); - - return $schema; - } - -} diff --git a/lib/class-wp-rest-post-search-handler.php b/lib/class-wp-rest-post-search-handler.php deleted file mode 100644 index 64d19174320121..00000000000000 --- a/lib/class-wp-rest-post-search-handler.php +++ /dev/null @@ -1,190 +0,0 @@ -type = 'post'; - - // Support all public post types except attachments. - $this->subtypes = array_diff( - array_values( - get_post_types( - array( - 'public' => true, - 'show_in_rest' => true, - ), - 'names' - ) - ), - array( 'attachment' ) - ); - } - - /** - * Searches the object type content for a given search request. - * - * @since 3.3.0 - * - * @param WP_REST_Request $request Full REST request. - * @return array Associative array containing an `WP_REST_Search_Handler::RESULT_IDS` containing - * an array of found IDs and `WP_REST_Search_Handler::RESULT_TOTAL` containing the - * total count for the matching search results. - */ - public function search_items( WP_REST_Request $request ) { - - // Get the post types to search for the current request. - $post_types = $request[ WP_REST_Search_Controller::PROP_SUBTYPE ]; - if ( in_array( WP_REST_Search_Controller::TYPE_ANY, $post_types, true ) ) { - $post_types = $this->subtypes; - } - - $query_args = array( - 'post_type' => $post_types, - 'post_status' => 'publish', - 'paged' => (int) $request['page'], - 'posts_per_page' => (int) $request['per_page'], - 'ignore_sticky_posts' => true, - 'fields' => 'ids', - ); - - if ( ! empty( $request['search'] ) ) { - $query_args['s'] = $request['search']; - } - - $query = new WP_Query(); - $found_ids = $query->query( $query_args ); - $total = $query->found_posts; - - return array( - self::RESULT_IDS => $found_ids, - self::RESULT_TOTAL => $total, - ); - } - - /** - * Prepares the search result for a given ID. - * - * @since 3.3.0 - * - * @param int $id Item ID. - * @param array $fields Fields to include for the item. - * @return array Associative array containing all fields for the item. - */ - public function prepare_item( $id, array $fields ) { - $post = get_post( $id ); - - $data = array(); - - if ( in_array( WP_REST_Search_Controller::PROP_ID, $fields, true ) ) { - $data[ WP_REST_Search_Controller::PROP_ID ] = (int) $post->ID; - } - - if ( in_array( WP_REST_Search_Controller::PROP_TITLE, $fields, true ) ) { - if ( post_type_supports( $post->post_type, 'title' ) ) { - add_filter( 'protected_title_format', array( $this, 'protected_title_format' ) ); - $data[ WP_REST_Search_Controller::PROP_TITLE ] = get_the_title( $post->ID ); - remove_filter( 'protected_title_format', array( $this, 'protected_title_format' ) ); - } else { - $data[ WP_REST_Search_Controller::PROP_TITLE ] = ''; - } - } - - if ( in_array( WP_REST_Search_Controller::PROP_URL, $fields, true ) ) { - $data[ WP_REST_Search_Controller::PROP_URL ] = get_permalink( $post->ID ); - } - - if ( in_array( WP_REST_Search_Controller::PROP_TYPE, $fields, true ) ) { - $data[ WP_REST_Search_Controller::PROP_TYPE ] = $this->type; - } - - if ( in_array( WP_REST_Search_Controller::PROP_SUBTYPE, $fields, true ) ) { - $data[ WP_REST_Search_Controller::PROP_SUBTYPE ] = $post->post_type; - } - - return $data; - } - - /** - * Prepares links for the search result of a given ID. - * - * @since 3.3.0 - * - * @param int $id Item ID. - * @return array Links for the given item. - */ - public function prepare_item_links( $id ) { - $post = get_post( $id ); - - $links = array(); - - $item_route = $this->detect_rest_item_route( $post ); - if ( ! empty( $item_route ) ) { - $links['self'] = array( - 'href' => rest_url( $item_route ), - 'embeddable' => true, - ); - } - - $links['about'] = array( - 'href' => rest_url( 'wp/v2/types/' . $post->post_type ), - ); - - return $links; - } - - /** - * Overwrites the default protected title format. - * - * By default, WordPress will show password protected posts with a title of - * "Protected: %s". As the REST API communicates the protected status of a post - * in a machine readable format, we remove the "Protected: " prefix. - * - * @since 3.3.0 - * - * @return string Protected title format. - */ - public function protected_title_format() { - return '%s'; - } - - /** - * Attempts to detect the route to access a single item. - * - * @since 3.3.0 - * - * @param WP_Post $post Post object. - * @return string REST route relative to the REST base URI, or empty string if unknown. - */ - protected function detect_rest_item_route( $post ) { - $post_type = get_post_type_object( $post->post_type ); - if ( ! $post_type ) { - return ''; - } - - // It's currently impossible to detect the REST URL from a custom controller. - if ( ! empty( $post_type->rest_controller_class ) && 'WP_REST_Posts_Controller' !== $post_type->rest_controller_class ) { - return ''; - } - - $namespace = 'wp/v2'; - $rest_base = ! empty( $post_type->rest_base ) ? $post_type->rest_base : $post_type->name; - - return sprintf( '%s/%s/%d', $namespace, $rest_base, $post->ID ); - } -} diff --git a/lib/class-wp-rest-search-controller.php b/lib/class-wp-rest-search-controller.php deleted file mode 100644 index 69776c5a7be342..00000000000000 --- a/lib/class-wp-rest-search-controller.php +++ /dev/null @@ -1,363 +0,0 @@ -namespace = 'wp/v2'; - $this->rest_base = 'search'; - - foreach ( $search_handlers as $search_handler ) { - if ( ! $search_handler instanceof WP_REST_Search_Handler ) { - - /* translators: %s: PHP class name */ - _doing_it_wrong( __METHOD__, sprintf( __( 'REST search handlers must extend the %s class.', 'gutenberg' ), 'WP_REST_Search_Handler' ), '3.3.0' ); - continue; - } - - $this->search_handlers[ $search_handler->get_type() ] = $search_handler; - } - } - - /** - * Registers the routes for the objects of the controller. - * - * @since 3.3.0 - * - * @see register_rest_route() - */ - public function register_routes() { - register_rest_route( - $this->namespace, - '/' . $this->rest_base, - array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_items' ), - 'permission_callback' => array( $this, 'get_items_permission_check' ), - 'args' => $this->get_collection_params(), - ), - 'schema' => array( $this, 'get_public_item_schema' ), - ) - ); - } - - /** - * Checks if a given request has access to search content. - * - * @since 3.3.0 - * - * @param WP_REST_Request $request Full details about the request. - * @return true|WP_Error True if the request has search access, WP_Error object otherwise. - */ - public function get_items_permission_check( $request ) { - return true; - } - - /** - * Retrieves a collection of search results. - * - * @since 3.3.0 - * - * @param WP_REST_Request $request Full details about the request. - * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. - */ - public function get_items( $request ) { - $handler = $this->get_search_handler( $request ); - if ( is_wp_error( $handler ) ) { - return $handler; - } - - $result = $handler->search_items( $request ); - - if ( ! isset( $result[ WP_REST_Search_Handler::RESULT_IDS ] ) || ! is_array( $result[ WP_REST_Search_Handler::RESULT_IDS ] ) || ! isset( $result[ WP_REST_Search_Handler::RESULT_TOTAL ] ) ) { - return new WP_Error( 'rest_search_handler_error', __( 'Internal search handler error.', 'gutenberg' ), array( 'status' => 500 ) ); - } - - $ids = array_map( 'absint', $result[ WP_REST_Search_Handler::RESULT_IDS ] ); - - $results = array(); - foreach ( $ids as $id ) { - $data = $this->prepare_item_for_response( $id, $request ); - $results[] = $this->prepare_response_for_collection( $data ); - } - - $total = (int) $result[ WP_REST_Search_Handler::RESULT_TOTAL ]; - $page = (int) $request['page']; - $per_page = (int) $request['per_page']; - $max_pages = ceil( $total / $per_page ); - - if ( $page > $max_pages && $total > 0 ) { - return new WP_Error( 'rest_search_invalid_page_number', __( 'The page number requested is larger than the number of pages available.', 'gutenberg' ), array( 'status' => 400 ) ); - } - - $response = rest_ensure_response( $results ); - $response->header( 'X-WP-Total', $total ); - $response->header( 'X-WP-TotalPages', $max_pages ); - - $request_params = $request->get_query_params(); - $base = add_query_arg( $request_params, rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) ); - - if ( $page > 1 ) { - $prev_link = add_query_arg( 'page', $page - 1, $base ); - $response->link_header( 'prev', $prev_link ); - } - if ( $page < $max_pages ) { - $next_link = add_query_arg( 'page', $page + 1, $base ); - $response->link_header( 'next', $next_link ); - } - - return $response; - } - - /** - * Prepares a single search result for response. - * - * @since 3.3.0 - * - * @param int $id ID of the item to prepare. - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response Response object. - */ - public function prepare_item_for_response( $id, $request ) { - $handler = $this->get_search_handler( $request ); - if ( is_wp_error( $handler ) ) { - return new WP_REST_Response(); - } - - if ( method_exists( $this, 'get_fields_for_response' ) ) { - $fields = $this->get_fields_for_response( $request ); - } else { - $schema = $this->get_item_schema(); - $fields = array_keys( $schema['properties'] ); - } - - $data = $handler->prepare_item( $id, $fields ); - $data = $this->add_additional_fields_to_object( $data, $request ); - - $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; - $data = $this->filter_response_by_context( $data, $context ); - - $response = rest_ensure_response( $data ); - - $links = $handler->prepare_item_links( $id ); - $links['collection'] = array( - 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), - ); - $response->add_links( $links ); - - return $response; - } - - /** - * Retrieves the item schema, conforming to JSON Schema. - * - * @since 3.3.0 - * - * @return array Item schema data. - */ - public function get_item_schema() { - $types = array(); - $subtypes = array(); - foreach ( $this->search_handlers as $search_handler ) { - $types[] = $search_handler->get_type(); - $subtypes = array_merge( $subtypes, $search_handler->get_subtypes() ); - } - - $types = array_unique( $types ); - $subtypes = array_unique( $subtypes ); - - $schema = array( - '$schema' => 'http://json-schema.org/draft-04/schema#', - 'title' => 'search-result', - 'type' => 'object', - 'properties' => array( - self::PROP_ID => array( - 'description' => __( 'Unique identifier for the object.', 'gutenberg' ), - 'type' => 'integer', - 'context' => array( 'view', 'embed' ), - 'readonly' => true, - ), - self::PROP_TITLE => array( - 'description' => __( 'The title for the object.', 'gutenberg' ), - 'type' => 'string', - 'context' => array( 'view', 'embed' ), - 'readonly' => true, - ), - self::PROP_URL => array( - 'description' => __( 'URL to the object.', 'gutenberg' ), - 'type' => 'string', - 'format' => 'uri', - 'context' => array( 'view', 'embed' ), - 'readonly' => true, - ), - self::PROP_TYPE => array( - 'description' => __( 'Object type.', 'gutenberg' ), - 'type' => 'string', - 'enum' => $types, - 'context' => array( 'view', 'embed' ), - 'readonly' => true, - ), - self::PROP_SUBTYPE => array( - 'description' => __( 'Object subtype.', 'gutenberg' ), - 'type' => 'string', - 'enum' => $subtypes, - 'context' => array( 'view', 'embed' ), - 'readonly' => true, - ), - ), - ); - - return $this->add_additional_fields_schema( $schema ); - } - - /** - * Retrieves the query params for the search results collection. - * - * @since 3.3.0 - * - * @return array Collection parameters. - */ - public function get_collection_params() { - $types = array(); - $subtypes = array(); - foreach ( $this->search_handlers as $search_handler ) { - $types[] = $search_handler->get_type(); - $subtypes = array_merge( $subtypes, $search_handler->get_subtypes() ); - } - - $types = array_unique( $types ); - $subtypes = array_unique( $subtypes ); - - $query_params = parent::get_collection_params(); - - $query_params['context']['default'] = 'view'; - - $query_params[ self::PROP_TYPE ] = array( - 'default' => $types[0], - 'description' => __( 'Limit results to items of an object type.', 'gutenberg' ), - 'type' => 'string', - 'enum' => $types, - ); - - $query_params[ self::PROP_SUBTYPE ] = array( - 'default' => self::TYPE_ANY, - 'description' => __( 'Limit results to items of one or more object subtypes.', 'gutenberg' ), - 'type' => 'array', - 'items' => array( - 'enum' => array_merge( $subtypes, array( self::TYPE_ANY ) ), - 'type' => 'string', - ), - 'sanitize_callback' => array( $this, 'sanitize_subtypes' ), - ); - - return $query_params; - } - - /** - * Sanitizes the list of subtypes, to ensure only subtypes of the passed type are included. - * - * @since 3.3.0 - * - * @param string|array $subtypes One or more subtypes. - * @param WP_REST_Request $request Full details about the request. - * @param string $parameter Parameter name. - * @return array|WP_Error List of valid subtypes, or WP_Error object on failure. - */ - public function sanitize_subtypes( $subtypes, $request, $parameter ) { - $subtypes = wp_parse_slug_list( $subtypes ); - - $subtypes = rest_parse_request_arg( $subtypes, $request, $parameter ); - if ( is_wp_error( $subtypes ) ) { - return $subtypes; - } - - // 'any' overrides any other subtype. - if ( in_array( self::TYPE_ANY, $subtypes, true ) ) { - return array( self::TYPE_ANY ); - } - - $handler = $this->get_search_handler( $request ); - if ( is_wp_error( $handler ) ) { - return $handler; - } - - return array_intersect( $subtypes, $handler->get_subtypes() ); - } - - /** - * Gets the search handler to handle the current request. - * - * @since 3.3.0 - * - * @param WP_REST_Request $request Full details about the request. - * @return WP_REST_Search_Handler|WP_Error Search handler for the request type, or WP_Error object on failure. - */ - protected function get_search_handler( $request ) { - $type = $request->get_param( self::PROP_TYPE ); - - if ( ! $type || ! isset( $this->search_handlers[ $type ] ) ) { - return new WP_Error( 'rest_search_invalid_type', __( 'Invalid type parameter.', 'gutenberg' ), array( 'status' => 400 ) ); - } - - return $this->search_handlers[ $type ]; - } -} diff --git a/lib/class-wp-rest-search-handler.php b/lib/class-wp-rest-search-handler.php deleted file mode 100644 index 6c5351d45b0029..00000000000000 --- a/lib/class-wp-rest-search-handler.php +++ /dev/null @@ -1,96 +0,0 @@ -type; - } - - /** - * Gets the object subtypes managed by this search handler. - * - * @since 3.3.0 - * - * @return array Array of object subtype identifiers. - */ - public function get_subtypes() { - return $this->subtypes; - } - - /** - * Searches the object type content for a given search request. - * - * @since 3.3.0 - * - * @param WP_REST_Request $request Full REST request. - * @return array Associative array containing an `WP_REST_Search_Handler::RESULT_IDS` containing - * an array of found IDs and `WP_REST_Search_Handler::RESULT_TOTAL` containing the - * total count for the matching search results. - */ - abstract public function search_items( WP_REST_Request $request ); - - /** - * Prepares the search result for a given ID. - * - * @since 3.3.0 - * - * @param int $id Item ID. - * @param array $fields Fields to include for the item. - * @return array Associative array containing all fields for the item. - */ - abstract public function prepare_item( $id, array $fields ); - - /** - * Prepares links for the search result of a given ID. - * - * @since 3.3.0 - * - * @param int $id Item ID. - * @return array Links for the given item. - */ - abstract public function prepare_item_links( $id ); -} diff --git a/lib/class-wp-rest-themes-controller.php b/lib/class-wp-rest-themes-controller.php deleted file mode 100644 index b2ddf4e0e39101..00000000000000 --- a/lib/class-wp-rest-themes-controller.php +++ /dev/null @@ -1,237 +0,0 @@ -namespace = 'wp/v2'; - $this->rest_base = 'themes'; - } - - /** - * Registers the routes for the objects of the controller. - * - * @since 5.0.0 - * - * @see register_rest_route() - */ - public function register_routes() { - register_rest_route( - $this->namespace, - '/' . $this->rest_base, - array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_items' ), - 'permission_callback' => array( $this, 'get_items_permissions_check' ), - 'args' => $this->get_collection_params(), - ), - 'schema' => array( $this, 'get_item_schema' ), - ) - ); - } - - /** - * Checks if a given request has access to read the theme. - * - * @since 5.0.0 - * - * @param WP_REST_Request $request Full details about the request. - * @return true|WP_Error True if the request has read access for the item, otherwise WP_Error object. - */ - public function get_items_permissions_check( $request ) { - if ( ! is_user_logged_in() || ! current_user_can( 'edit_posts' ) ) { - return new WP_Error( 'rest_user_cannot_view', __( 'Sorry, you are not allowed to view themes.', 'gutenberg' ), array( 'status' => rest_authorization_required_code() ) ); - } - - return true; - } - - /** - * Retrieves a collection of themes. - * - * @since 5.0.0 - * - * @param WP_REST_Request $request Full details about the request. - * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. - */ - public function get_items( $request ) { - // Retrieve the list of registered collection query parameters. - $registered = $this->get_collection_params(); - $themes = array(); - - if ( isset( $registered['status'], $request['status'] ) && in_array( 'active', $request['status'], true ) ) { - $active_theme = wp_get_theme(); - $active_theme = $this->prepare_item_for_response( $active_theme, $request ); - $themes[] = $this->prepare_response_for_collection( $active_theme ); - } - - $response = rest_ensure_response( $themes ); - - $response->header( 'X-WP-Total', count( $themes ) ); - $response->header( 'X-WP-TotalPages', count( $themes ) ); - - return $response; - } - - /** - * Prepares a single theme output for response. - * - * @since 5.0.0 - * - * @param WP_Theme $theme Theme object. - * @param WP_REST_Request $request Request object. - * @return WP_REST_Response Response object. - */ - public function prepare_item_for_response( $theme, $request ) { - $data = array(); - $fields = $this->get_fields_for_response( $request ); - - if ( in_array( 'theme_supports', $fields, true ) ) { - $formats = get_theme_support( 'post-formats' ); - $formats = is_array( $formats ) ? array_values( $formats[0] ) : array(); - $formats = array_merge( array( 'standard' ), $formats ); - $data['theme_supports']['formats'] = $formats; - - $data['theme_supports']['post-thumbnails'] = false; - $data['theme_supports']['responsive-embeds'] = (bool) get_theme_support( 'responsive-embeds' ); - $post_thumbnails = get_theme_support( 'post-thumbnails' ); - - if ( $post_thumbnails ) { - // $post_thumbnails can contain a nested array of post types. - // e.g. array( array( 'post', 'page' ) ). - $data['theme_supports']['post-thumbnails'] = is_array( $post_thumbnails ) ? $post_thumbnails[0] : true; - } - } - - $data = $this->add_additional_fields_to_object( $data, $request ); - - // Wrap the data in a response object. - $response = rest_ensure_response( $data ); - - /** - * Filters theme data returned from the REST API. - * - * @since 5.0.0 - * - * @param WP_REST_Response $response The response object. - * @param WP_Theme $theme Theme object used to create response. - * @param WP_REST_Request $request Request object. - */ - return apply_filters( 'rest_prepare_theme', $response, $theme, $request ); - } - - /** - * Retrieves the theme's schema, conforming to JSON Schema. - * - * @since 5.0.0 - * - * @return array Item schema data. - */ - public function get_item_schema() { - $schema = array( - '$schema' => 'http://json-schema.org/draft-04/schema#', - 'title' => 'theme', - 'type' => 'object', - 'properties' => array( - 'theme_supports' => array( - 'description' => __( 'Features supported by this theme.', 'gutenberg' ), - 'type' => 'array', - 'readonly' => true, - 'properties' => array( - 'formats' => array( - 'description' => __( 'Post formats supported.', 'gutenberg' ), - 'type' => 'array', - 'readonly' => true, - ), - 'post-thumbnails' => array( - 'description' => __( 'Whether the theme supports post thumbnails.', 'gutenberg' ), - 'type' => array( 'array', 'bool' ), - 'readonly' => true, - ), - 'responsive-embeds' => array( - 'description' => __( 'Whether the theme supports responsive embedded content.', 'gutenberg' ), - 'type' => 'bool', - 'readonly' => true, - ), - ), - ), - ), - ); - - return $this->add_additional_fields_schema( $schema ); - } - - /** - * Retrieves the search params for the themes collection. - * - * @since 5.0.0 - * - * @return array Collection parameters. - */ - public function get_collection_params() { - $query_params = parent::get_collection_params(); - - $query_params['status'] = array( - 'description' => __( 'Limit result set to themes assigned one or more statuses.', 'gutenberg' ), - 'type' => 'array', - 'items' => array( - 'enum' => array( 'active' ), - 'type' => 'string', - ), - 'required' => true, - 'sanitize_callback' => array( $this, 'sanitize_theme_status' ), - ); - - /** - * Filter collection parameters for the themes controller. - * - * @since 5.0.0 - * - * @param array $query_params JSON Schema-formatted collection parameters. - */ - return apply_filters( 'rest_themes_collection_params', $query_params ); - } - - /** - * Sanitizes and validates the list of theme status. - * - * @since 5.0.0 - * - * @param string|array $statuses One or more theme statuses. - * @param WP_REST_Request $request Full details about the request. - * @param string $parameter Additional parameter to pass to validation. - * @return array|WP_Error A list of valid statuses, otherwise WP_Error object. - */ - public function sanitize_theme_status( $statuses, $request, $parameter ) { - $statuses = wp_parse_slug_list( $statuses ); - - foreach ( $statuses as $status ) { - $result = rest_validate_request_arg( $status, $request, $parameter ); - - if ( is_wp_error( $result ) ) { - return $result; - } - } - - return $statuses; - } -} diff --git a/lib/class-wp-rest-widget-updater-controller.php b/lib/class-wp-rest-widget-updater-controller.php new file mode 100644 index 00000000000000..23c46887ba573b --- /dev/null +++ b/lib/class-wp-rest-widget-updater-controller.php @@ -0,0 +1,186 @@ +namespace = 'wp/v2'; + $this->rest_base = 'widgets'; + } + + /** + * Registers the necessary REST API route. + * + * @access public + */ + public function register_routes() { + register_rest_route( + $this->namespace, + // Regex representing a PHP class extracted from http://php.net/manual/en/language.oop5.basic.php. + '/' . $this->rest_base . '/(?P[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)/', + array( + 'args' => array( + 'identifier' => array( + 'description' => __( 'Class name of the widget.', 'gutenberg' ), + 'type' => 'string', + ), + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'permission_callback' => array( $this, 'compute_new_widget_permissions_check' ), + 'callback' => array( $this, 'compute_new_widget' ), + ), + ) + ); + } + + /** + * Checks if the user has permissions to make the request. + * + * @since 5.2.0 + * @access public + * + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function compute_new_widget_permissions_check() { + // Verify if the current user has edit_theme_options capability. + // This capability is required to access the widgets screen. + if ( ! current_user_can( 'edit_theme_options' ) ) { + return new WP_Error( + 'widgets_cannot_access', + __( 'Sorry, you are not allowed to access widgets on this site.', 'gutenberg' ), + array( + 'status' => rest_authorization_required_code(), + ) + ); + } + return true; + } + + /** + * Returns the new widget instance and the form that represents it. + * + * @since 5.2.0 + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function compute_new_widget( $request ) { + $url_params = $request->get_url_params(); + + $widget = $request->get_param( 'identifier' ); + + global $wp_widget_factory; + + if ( + null === $widget || + ! isset( $wp_widget_factory->widgets[ $widget ] ) || + ! ( $wp_widget_factory->widgets[ $widget ] instanceof WP_Widget ) + ) { + return new WP_Error( + 'widget_invalid', + __( 'Invalid widget.', 'gutenberg' ), + array( + 'status' => 404, + ) + ); + } + + $widget_obj = $wp_widget_factory->widgets[ $widget ]; + + $instance = $request->get_param( 'instance' ); + if ( null === $instance ) { + $instance = array(); + } + $id_to_use = $request->get_param( 'id_to_use' ); + if ( null === $id_to_use ) { + $id_to_use = -1; + } + + $widget_obj->_set( $id_to_use ); + ob_start(); + + $instance_changes = $request->get_param( 'instance_changes' ); + if ( null !== $instance_changes ) { + $old_instance = $instance; + $instance = $widget_obj->update( $instance_changes, $old_instance ); + /** + * Filters a widget's settings before saving. + * + * Returning false will effectively short-circuit the widget's ability + * to update settings. The old setting will be returned. + * + * @since 5.2.0 + * + * @param array $instance The current widget instance's settings. + * @param array $instance_changes Array of new widget settings. + * @param array $old_instance Array of old widget settings. + * @param WP_Widget $widget_ob The widget instance. + */ + $instance = apply_filters( 'widget_update_callback', $instance, $instance_changes, $old_instance, $widget_obj ); + if ( false === $instance ) { + $instance = $old_instance; + } + } + + $instance = apply_filters( 'widget_form_callback', $instance, $widget_obj ); + + $return = null; + if ( false !== $instance ) { + $return = $widget_obj->form( $instance ); + + /** + * Fires at the end of the widget control form. + * + * Use this hook to add extra fields to the widget form. The hook + * is only fired if the value passed to the 'widget_form_callback' + * hook is not false. + * + * Note: If the widget has no form, the text echoed from the default + * form method can be hidden using CSS. + * + * @since 5.2.0 + * + * @param WP_Widget $widget_obj The widget instance (passed by reference). + * @param null $return Return null if new fields are added. + * @param array $instance An array of the widget's settings. + */ + do_action_ref_array( 'in_widget_form', array( &$widget_obj, &$return, $instance ) ); + } + + $id_base = $widget_obj->id_base; + $id = $widget_obj->id; + $form = ob_get_clean(); + + return rest_ensure_response( + array( + 'instance' => $instance, + 'form' => $form, + 'id_base' => $id_base, + 'id' => $id, + ) + ); + } +} +/** + * End: Include for phase 2 + */ diff --git a/lib/client-assets.php b/lib/client-assets.php index 13672f6516bcdd..2a97319dd9f1c3 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -35,83 +35,118 @@ function gutenberg_url( $path ) { } /** - * Returns contents of an inline script used in appending polyfill scripts for - * browsers which fail the provided tests. The provided array is a mapping from - * a condition to verify feature support to its polyfill script handle. + * Registers a script according to `wp_register_script`. Honors this request by + * reassigning internal dependency properties of any script handle already + * registered by that name. It does not deregister the original script, to + * avoid losing inline scripts which may have been attached. + * + * @since 4.1.0 * - * @param array $tests Features to detect. - * @return string Conditional polyfill inline script. + * @param string $handle Name of the script. Should be unique. + * @param string $src Full URL of the script, or path of the script relative to the WordPress root directory. + * @param array $deps Optional. An array of registered script handles this script depends on. Default empty array. + * @param string|bool|null $ver Optional. String specifying script version number, if it has one, which is added to the URL + * as a query string for cache busting purposes. If 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. + * @param bool $in_footer Optional. Whether to enqueue the script before instead of in the . + * Default 'false'. */ -function gutenberg_get_script_polyfill( $tests ) { +function gutenberg_override_script( $handle, $src, $deps = array(), $ver = false, $in_footer = false ) { global $wp_scripts; - $polyfill = ''; - foreach ( $tests as $test => $handle ) { - if ( ! array_key_exists( $handle, $wp_scripts->registered ) ) { - continue; - } + $script = $wp_scripts->query( $handle, 'registered' ); + if ( $script ) { + /* + * In many ways, this is a reimplementation of `wp_register_script` but + * bypassing consideration of whether a script by the given handle had + * already been registered. + */ - $polyfill .= ( - // Test presence of feature... - '( ' . $test . ' ) || ' . - // ...appending polyfill on any failures. Cautious viewers may balk - // at the `document.write`. Its caveat of synchronous mid-stream - // blocking write is exactly the behavior we need though. - 'document.write( \' - true, - 'action' => 'edit', - 'classic-editor' => true, - 'post' => $post_id, - ), - admin_url( 'post.php' ) - ); - } - - return $location; -} - -add_filter( 'redirect_post_location', 'gutenberg_meta_box_save_redirect', 10, 2 ); - -/** - * Filter out core meta boxes as well as the post thumbnail. - * - * @since 1.5.0 - * - * @param array $meta_boxes Meta box data. - * @return array Meta box data without core meta boxes. - */ -function gutenberg_filter_meta_boxes( $meta_boxes ) { - $core_side_meta_boxes = array( - 'submitdiv', - 'formatdiv', - 'pageparentdiv', - 'postimagediv', - ); - - $custom_taxonomies = get_taxonomies( - array( - 'show_ui' => true, - ), - 'objects' - ); - - // Following the same logic as meta box generation in: - // https://github.com/WordPress/wordpress-develop/blob/c896326/src/wp-admin/edit-form-advanced.php#L288-L292. - foreach ( $custom_taxonomies as $custom_taxonomy ) { - $core_side_meta_boxes [] = $custom_taxonomy->hierarchical ? - $custom_taxonomy->name . 'div' : - 'tagsdiv-' . $custom_taxonomy->name; - } - - $core_normal_meta_boxes = array( - 'revisionsdiv', - 'postexcerpt', - 'trackbacksdiv', - 'commentstatusdiv', - 'commentsdiv', - 'slugdiv', - 'authordiv', - ); - - // Whether or not to load the 'postcustom' meta box is stored as a user meta - // field so that we're not always loading its assets. - $enable_custom_fields = (bool) get_user_meta( get_current_user_id(), 'enable_custom_fields', true ); - if ( ! $enable_custom_fields ) { - $core_normal_meta_boxes[] = 'postcustom'; - } - - $taxonomy_callbacks_to_unset = array( - 'post_tags_meta_box', - 'post_categories_meta_box', - ); - - foreach ( $meta_boxes as $page => $contexts ) { - foreach ( $contexts as $context => $priorities ) { - foreach ( $priorities as $priority => $boxes ) { - foreach ( $boxes as $name => $data ) { - if ( 'normal' === $context && in_array( $name, $core_normal_meta_boxes ) ) { - unset( $meta_boxes[ $page ][ $context ][ $priority ][ $name ] ); - } elseif ( 'side' === $context && in_array( $name, $core_side_meta_boxes ) ) { - unset( $meta_boxes[ $page ][ $context ][ $priority ][ $name ] ); - } - // Filter out any taxonomies as Gutenberg already provides JS alternative. - if ( isset( $data['callback'] ) && in_array( $data['callback'], $taxonomy_callbacks_to_unset ) ) { - unset( $meta_boxes[ $page ][ $context ][ $priority ][ $name ] ); - } - // Filter out meta boxes that are just registered for back compat. - if ( isset( $data['args']['__back_compat_meta_box'] ) && $data['args']['__back_compat_meta_box'] ) { - unset( $meta_boxes[ $page ][ $context ][ $priority ][ $name ] ); - } - } - } - } - } - - return $meta_boxes; -} - -add_filter( 'filter_gutenberg_meta_boxes', 'gutenberg_filter_meta_boxes' ); - -/** - * Go through the global metaboxes, and override the render callback, so we can trigger our warning if needed. - * - * @since 1.8.0 - */ -function gutenberg_intercept_meta_box_render() { - global $wp_meta_boxes; - - foreach ( $wp_meta_boxes as $post_type => $contexts ) { - foreach ( $contexts as $context => $priorities ) { - foreach ( $priorities as $priority => $boxes ) { - foreach ( $boxes as $id => $box ) { - if ( ! is_array( $box ) ) { - continue; - } - if ( ! is_array( $wp_meta_boxes[ $post_type ][ $context ][ $priority ][ $id ]['args'] ) ) { - $wp_meta_boxes[ $post_type ][ $context ][ $priority ][ $id ]['args'] = array(); - } - if ( ! isset( $wp_meta_boxes[ $post_type ][ $context ][ $priority ][ $id ]['args']['__original_callback'] ) ) { - $wp_meta_boxes[ $post_type ][ $context ][ $priority ][ $id ]['args']['__original_callback'] = $box['callback']; - $wp_meta_boxes[ $post_type ][ $context ][ $priority ][ $id ]['callback'] = 'gutenberg_override_meta_box_callback'; - } - } - } - } - } -} -add_action( 'submitpost_box', 'gutenberg_intercept_meta_box_render' ); -add_action( 'submitpage_box', 'gutenberg_intercept_meta_box_render' ); -add_action( 'edit_page_form', 'gutenberg_intercept_meta_box_render' ); -add_action( 'edit_form_advanced', 'gutenberg_intercept_meta_box_render' ); - -/** - * Check if this metabox only exists for back compat purposes, show a warning if it doesn't. - * - * @since 1.8.0 - * - * @param mixed $object The object being operated on, on this screen. - * @param array $box The current meta box definition. - */ -function gutenberg_override_meta_box_callback( $object, $box ) { - $callback = $box['args']['__original_callback']; - unset( $box['args']['__original_callback'] ); - - $block_compatible = true; - if ( isset( $box['args']['__block_editor_compatible_meta_box'] ) ) { - $block_compatible = (bool) $box['args']['__block_editor_compatible_meta_box']; - unset( $box['args']['__block_editor_compatible_meta_box'] ); - } - - if ( isset( $box['args']['__back_compat_meta_box'] ) ) { - $block_compatible |= (bool) $box['args']['__back_compat_meta_box']; - unset( $box['args']['__back_compat_meta_box'] ); - } - - if ( ! $block_compatible ) { - gutenberg_show_meta_box_warning( $callback ); - } - - call_user_func( $callback, $object, $box ); -} - -/** - * Display a warning in the metabox that the current plugin is causing the fallback to the old editor. - * - * @since 1.8.0 - * - * @param callable $callback The function that a plugin has defined to render a meta box. - */ -function gutenberg_show_meta_box_warning( $callback ) { - // Only show the warning when WP_DEBUG is enabled. - if ( ! WP_DEBUG ) { - return; - } - - // Don't show in the Gutenberg meta box UI. - if ( ! isset( $_REQUEST['classic-editor'] ) ) { - return; - } - - try { - if ( is_array( $callback ) ) { - $reflection = new ReflectionMethod( $callback[0], $callback[1] ); - } else { - $reflection = new ReflectionFunction( $callback ); - } - } catch ( ReflectionException $exception ) { - // We could not properly reflect on the callable, so we abort here. - return; - } - - if ( $reflection->isInternal() ) { - return; - } - - $filename = $reflection->getFileName(); - if ( strpos( $filename, WP_PLUGIN_DIR ) !== 0 ) { - return; - } - - $filename = str_replace( WP_PLUGIN_DIR, '', $filename ); - $filename = preg_replace( '|^/([^/]*/).*$|', '\\1', $filename ); - - $plugins = get_plugins(); - foreach ( $plugins as $name => $plugin ) { - if ( strpos( $name, $filename ) === 0 ) { - ?> -
-

- -

-
- -
- -
-
- - -
- -
- -
- - id ][ $location ][ $priority ] ) ) { - $meta_boxes = (array) $wp_meta_boxes[ $current_screen->id ][ $location ][ $priority ]; - foreach ( $meta_boxes as $meta_box ) { - if ( false == $meta_box || ! $meta_box['title'] ) { - continue; - } - - $meta_boxes_per_location[ $location ][] = array( - 'id' => $meta_box['id'], - 'title' => $meta_box['title'], - ); - } - } - } - } - - /** - * Sadly we probably can not add this data directly into editor settings. - * - * ACF and other meta boxes need admin_head to fire for meta box registry. - * admin_head fires after admin_enqueue_scripts which is where we create our - * editor instance. If a cleaner solution can be imagined, please change - * this, and try to get this data to load directly into the editor settings. - */ - $script = 'window._wpLoadGutenbergEditor.then( function() { - wp.data.dispatch( \'core/edit-post\' ).setAvailableMetaBoxesPerLocation( ' . wp_json_encode( $meta_boxes_per_location ) . ' ); - } );'; - - wp_add_inline_script( 'wp-edit-post', $script ); - - /** - * When `wp-edit-post` is output in the ``, the inline script needs to be manually printed. Otherwise, - * metaboxes will not display because inline scripts for `wp-edit-post` will not be printed again after this point. - * - * @see https://github.com/WordPress/gutenberg/issues/6963 - */ - if ( wp_script_is( 'wp-edit-post', 'done' ) ) { - printf( "\n", trim( $script ) ); - } - - /** - * If the 'postcustom' meta box is enabled, then we need to perform some - * extra initialization on it. - */ - $enable_custom_fields = (bool) get_user_meta( get_current_user_id(), 'enable_custom_fields', true ); - if ( $enable_custom_fields ) { - $script = "( function( $ ) { - if ( $('#postcustom').length ) { - $( '#the-list' ).wpList( { - addBefore: function( s ) { - s.data += '&post_id=$post->ID'; - return s; - }, - addAfter: function() { - $('table#list-table').show(); - } - }); - } - } )( jQuery );"; - - wp_enqueue_script( 'wp-lists' ); - wp_add_inline_script( 'wp-lists', $script ); - } - - // Reset meta box data. - $wp_meta_boxes = $_original_meta_boxes; -} - -/** - * Renders the hidden form required for the meta boxes form. - * - * @param WP_Post $post Current post object. - * - * @since 1.8.0 - */ -function gutenberg_meta_box_post_form_hidden_fields( $post ) { - $form_extra = ''; - if ( 'auto-draft' === $post->post_status ) { - $form_extra .= ""; - } - $form_action = 'editpost'; - $nonce_action = 'update-post_' . $post->ID; - $form_extra .= ""; - $referer = wp_get_referer(); - $current_user = wp_get_current_user(); - $user_id = $current_user->ID; - wp_nonce_field( $nonce_action ); - ?> - - - - - - - - - - array( - 'wp-hooks', 'wp-i18n', 'wp-url', ), @@ -28,6 +27,7 @@ 'wp-api-fetch', 'wp-autop', 'wp-blob', + 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', @@ -45,6 +45,28 @@ ), 'wp-block-serialization-default-parser' => array(), 'wp-block-serialization-spec-parser' => array(), + 'wp-block-editor' => array( + 'lodash', + 'wp-a11y', + 'wp-blob', + 'wp-blocks', + 'wp-compose', + 'wp-components', + 'wp-core-data', + 'wp-data', + 'wp-dom', + 'wp-element', + 'wp-hooks', + 'wp-html-entities', + 'wp-i18n', + 'wp-is-shallow-equal', + 'wp-keycodes', + 'wp-rich-text', + 'wp-token-list', + 'wp-url', + 'wp-viewport', + 'wp-wordcount', + ), 'wp-blocks' => array( 'lodash', 'wp-autop', @@ -84,6 +106,7 @@ 'lodash', 'wp-api-fetch', 'wp-data', + 'wp-deprecated', 'wp-url', ), 'wp-data' => array( @@ -91,6 +114,7 @@ 'wp-compose', 'wp-element', 'wp-is-shallow-equal', + 'wp-priority-queue', 'wp-redux-routine', ), 'wp-date' => array( @@ -101,7 +125,6 @@ ), 'wp-dom' => array( 'lodash', - 'wp-tinymce', ), 'wp-dom-ready' => array(), 'wp-edit-post' => array( @@ -112,6 +135,7 @@ 'media-views', 'wp-a11y', 'wp-api-fetch', + 'wp-block-editor', 'wp-block-library', 'wp-blocks', 'wp-components', @@ -130,13 +154,15 @@ 'wp-url', 'wp-viewport', ), + 'wp-edit-widgets' => array( + 'wp-element', + ), 'wp-editor' => array( - 'jquery', 'lodash', - 'wp-tinymce-lists', 'wp-a11y', 'wp-api-fetch', 'wp-blob', + 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', @@ -154,7 +180,6 @@ 'wp-notices', 'wp-nux', 'wp-rich-text', - 'wp-tinymce', 'wp-token-list', 'wp-url', 'wp-viewport', @@ -168,8 +193,8 @@ ), 'wp-escape-html' => array(), 'wp-format-library' => array( + 'wp-block-editor', 'wp-components', - 'wp-dom', 'wp-editor', 'wp-element', 'wp-i18n', @@ -212,6 +237,7 @@ 'wp-element', 'wp-hooks', ), + 'wp-priority-queue' => array(), 'wp-redux-routine' => array(), 'wp-rich-text' => array( 'lodash', diff --git a/lib/parser.php b/lib/parser.php deleted file mode 100644 index 8fbcaa19bb458f..00000000000000 --- a/lib/parser.php +++ /dev/null @@ -1,1681 +0,0 @@ -= $range[0] && $code <= $range[1]) { - return true; - } - } - return false; - } -} - -/* Syntax error exception */ -if (!class_exists("Gutenberg_PEG_SyntaxError", false)) { - class Gutenberg_PEG_SyntaxError extends Exception { - public $expected; - public $found; - public $grammarOffset; - public $grammarLine; - public $grammarColumn; - public $name; - public function __construct($message, $expected, $found, $offset, $line, $column) { - parent::__construct($message, 0); - $this->expected = $expected; - $this->found = $found; - $this->grammarOffset = $offset; - $this->grammarLine = $line; - $this->grammarColumn = $column; - $this->name = "Gutenberg_PEG_SyntaxError"; - } - } -} - -class Gutenberg_PEG_Parser { - private $peg_currPos = 0; - private $peg_reportedPos = 0; - private $peg_cachedPos = 0; - private $peg_cachedPosDetails = array('line' => 1, 'column' => 1, 'seenCR' => false ); - private $peg_maxFailPos = 0; - private $peg_maxFailExpected = array(); - private $peg_silentFails = 0; - private $input = array(); - private $input_length = 0; - - private function cleanup_state() { - $this->peg_currPos = 0; - $this->peg_reportedPos = 0; - $this->peg_cachedPos = 0; - $this->peg_cachedPosDetails = array('line' => 1, 'column' => 1, 'seenCR' => false ); - $this->peg_maxFailPos = 0; - $this->peg_maxFailExpected = array(); - $this->peg_silentFails = 0; - $this->input = array(); - $this->input_length = 0; - - } - - private function input_substr($start, $length) { - if ($length === 1 && $start < $this->input_length) { - return $this->input[$start]; - } - $substr = ''; - $max = min($start + $length, $this->input_length); - for ($i = $start; $i < $max; $i++) { - $substr .= $this->input[$i]; - } - return $substr; - } - - - private function text() { - return substr($this->input, $this->peg_reportedPos, $this->peg_reportedPos + $this->peg_currPos); - } - - private function offset() { - return $this->peg_reportedPos; - } - - private function line() { - $compute_pd = $this->peg_computePosDetails($this->peg_reportedPos); - return $compute_pd["line"]; - } - - private function column() { - $compute_pd = $this->peg_computePosDetails($this->peg_reportedPos); - return $compute_pd["column"]; - } - - private function expected($description) { - throw $this->peg_buildException( - null, - array(array("type" => "other", "description" => $description )), - $this->peg_reportedPos - ); - } - - private function error($message) { - throw $this->peg_buildException($message, null, $this->peg_reportedPos); - } - - private function peg_advancePos(&$details, $startPos, $endPos) { - for ($p = $startPos; $p < $endPos; $p++) { - $ch = $this->input_substr($p, 1); - if ($ch === "\n") { - if (!$details["seenCR"]) { $details["line"]++; } - $details["column"] = 1; - $details["seenCR"] = false; - } else if ($ch === "\r" || $ch === "\u2028" || $ch === "\u2029") { - $details["line"]++; - $details["column"] = 1; - $details["seenCR"] = true; - } else { - $details["column"]++; - $details["seenCR"] = false; - } - } - } - - private function peg_computePosDetails($pos) { - if ($this->peg_cachedPos !== $pos) { - if ($this->peg_cachedPos > $pos) { - $this->peg_cachedPos = 0; - $this->peg_cachedPosDetails = array( "line" => 1, "column" => 1, "seenCR" => false ); - } - $this->peg_advancePos($this->peg_cachedPosDetails, $this->peg_cachedPos, $pos); - $this->peg_cachedPos = $pos; - } - - return $this->peg_cachedPosDetails; - } - - private function peg_fail($expected) { - if ($this->peg_currPos < $this->peg_maxFailPos) { return; } - - if ($this->peg_currPos > $this->peg_maxFailPos) { - $this->peg_maxFailPos = $this->peg_currPos; - $this->peg_maxFailExpected = array(); - } - - $this->peg_maxFailExpected[] = $expected; - } - - private function peg_buildException_expectedComparator($a, $b) { - if ($a["description"] < $b["description"]) { - return -1; - } else if ($a["description"] > $b["description"]) { - return 1; - } else { - return 0; - } - } - - private function peg_buildException($message, $expected, $pos) { - $posDetails = $this->peg_computePosDetails($pos); - $found = $pos < $this->input_length ? $this->input[$pos] : null; - - if ($expected !== null) { - usort($expected, array($this, "peg_buildException_expectedComparator")); - $i = 1; - while ($i < count($expected)) { - if ($expected[$i - 1] === $expected[$i]) { - array_splice($expected, $i, 1); - } else { - $i++; - } - } - } - - if ($message === null) { - $expectedDescs = array_fill(0, count($expected), null); - - for ($i = 0; $i < count($expected); $i++) { - $expectedDescs[$i] = $expected[$i]["description"]; - } - - $expectedDesc = count($expected) > 1 - ? join(", ", array_slice($expectedDescs, 0, -1)) - . " or " - . $expectedDescs[count($expected) - 1] - : $expectedDescs[0]; - - $foundDesc = $found ? json_encode($found) : "end of input"; - - $message = "Expected " . $expectedDesc . " but " . $foundDesc . " found."; - } - - return new Gutenberg_PEG_SyntaxError( - $message, - $expected, - $found, - $pos, - $posDetails["line"], - $posDetails["column"] - ); - } - - private $peg_FAILED; - private $peg_c0; - private $peg_c1; - private $peg_c2; - private $peg_c3; - private $peg_c4; - private $peg_c5; - private $peg_c6; - private $peg_c7; - private $peg_c8; - private $peg_c9; - private $peg_c10; - private $peg_c11; - private $peg_c12; - private $peg_c13; - private $peg_c14; - private $peg_c15; - private $peg_c16; - private $peg_c17; - private $peg_c18; - private $peg_c19; - private $peg_c20; - private $peg_c21; - private $peg_c22; - private $peg_c23; - private $peg_c24; - - private function peg_f0($pre, $b, $html) { return array( $b, $html ); } - private function peg_f1($pre, $bs, $post) { return peg_join_blocks( $pre, $bs, $post ); } - private function peg_f2($blockName, $a) { return $a; } - private function peg_f3($blockName, $attrs) { - return array( - 'blockName' => $blockName, - 'attrs' => empty( $attrs ) ? peg_empty_attrs() : $attrs, - 'innerBlocks' => array(), - 'innerHTML' => '', - 'innerContent' => array(), - ); - } - private function peg_f4($s, $children, $e) { - 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, - ); - } - private function peg_f5($blockName, $attrs) { - return array( - 'blockName' => $blockName, - 'attrs' => isset( $attrs ) ? $attrs : array(), - ); - } - private function peg_f6($blockName) { - return array( - 'blockName' => $blockName, - ); - } - private function peg_f7($type) { return "core/$type"; } - private function peg_f8($attrs) { return json_decode( $attrs, true ); } - - private function peg_parseBlock_List() { - - $s0 = $this->peg_currPos; - $s1 = $this->peg_currPos; - $s2 = array(); - $s3 = $this->peg_currPos; - $s4 = $this->peg_currPos; - $this->peg_silentFails++; - $s5 = $this->peg_parseBlock(); - $this->peg_silentFails--; - if ($s5 === $this->peg_FAILED) { - $s4 = null; - } else { - $this->peg_currPos = $s4; - $s4 = $this->peg_FAILED; - } - if ($s4 !== $this->peg_FAILED) { - if ($this->input_length > $this->peg_currPos) { - $s5 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s5 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c0); - } - } - if ($s5 !== $this->peg_FAILED) { - $s4 = array($s4, $s5); - $s3 = $s4; - } else { - $this->peg_currPos = $s3; - $s3 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s3; - $s3 = $this->peg_FAILED; - } - while ($s3 !== $this->peg_FAILED) { - $s2[] = $s3; - $s3 = $this->peg_currPos; - $s4 = $this->peg_currPos; - $this->peg_silentFails++; - $s5 = $this->peg_parseBlock(); - $this->peg_silentFails--; - if ($s5 === $this->peg_FAILED) { - $s4 = null; - } else { - $this->peg_currPos = $s4; - $s4 = $this->peg_FAILED; - } - if ($s4 !== $this->peg_FAILED) { - if ($this->input_length > $this->peg_currPos) { - $s5 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s5 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c0); - } - } - if ($s5 !== $this->peg_FAILED) { - $s4 = array($s4, $s5); - $s3 = $s4; - } else { - $this->peg_currPos = $s3; - $s3 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s3; - $s3 = $this->peg_FAILED; - } - } - if ($s2 !== $this->peg_FAILED) { - $s1 = $this->input_substr($s1, $this->peg_currPos - $s1); - } else { - $s1 = $s2; - } - if ($s1 !== $this->peg_FAILED) { - $s2 = array(); - $s3 = $this->peg_currPos; - $s4 = $this->peg_parseBlock(); - if ($s4 !== $this->peg_FAILED) { - $s5 = $this->peg_currPos; - $s6 = array(); - $s7 = $this->peg_currPos; - $s8 = $this->peg_currPos; - $this->peg_silentFails++; - $s9 = $this->peg_parseBlock(); - $this->peg_silentFails--; - if ($s9 === $this->peg_FAILED) { - $s8 = null; - } else { - $this->peg_currPos = $s8; - $s8 = $this->peg_FAILED; - } - if ($s8 !== $this->peg_FAILED) { - if ($this->input_length > $this->peg_currPos) { - $s9 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s9 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c0); - } - } - if ($s9 !== $this->peg_FAILED) { - $s8 = array($s8, $s9); - $s7 = $s8; - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - while ($s7 !== $this->peg_FAILED) { - $s6[] = $s7; - $s7 = $this->peg_currPos; - $s8 = $this->peg_currPos; - $this->peg_silentFails++; - $s9 = $this->peg_parseBlock(); - $this->peg_silentFails--; - if ($s9 === $this->peg_FAILED) { - $s8 = null; - } else { - $this->peg_currPos = $s8; - $s8 = $this->peg_FAILED; - } - if ($s8 !== $this->peg_FAILED) { - if ($this->input_length > $this->peg_currPos) { - $s9 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s9 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c0); - } - } - if ($s9 !== $this->peg_FAILED) { - $s8 = array($s8, $s9); - $s7 = $s8; - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - } - if ($s6 !== $this->peg_FAILED) { - $s5 = $this->input_substr($s5, $this->peg_currPos - $s5); - } else { - $s5 = $s6; - } - if ($s5 !== $this->peg_FAILED) { - $this->peg_reportedPos = $s3; - $s4 = $this->peg_f0($s1, $s4, $s5); - $s3 = $s4; - } else { - $this->peg_currPos = $s3; - $s3 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s3; - $s3 = $this->peg_FAILED; - } - while ($s3 !== $this->peg_FAILED) { - $s2[] = $s3; - $s3 = $this->peg_currPos; - $s4 = $this->peg_parseBlock(); - if ($s4 !== $this->peg_FAILED) { - $s5 = $this->peg_currPos; - $s6 = array(); - $s7 = $this->peg_currPos; - $s8 = $this->peg_currPos; - $this->peg_silentFails++; - $s9 = $this->peg_parseBlock(); - $this->peg_silentFails--; - if ($s9 === $this->peg_FAILED) { - $s8 = null; - } else { - $this->peg_currPos = $s8; - $s8 = $this->peg_FAILED; - } - if ($s8 !== $this->peg_FAILED) { - if ($this->input_length > $this->peg_currPos) { - $s9 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s9 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c0); - } - } - if ($s9 !== $this->peg_FAILED) { - $s8 = array($s8, $s9); - $s7 = $s8; - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - while ($s7 !== $this->peg_FAILED) { - $s6[] = $s7; - $s7 = $this->peg_currPos; - $s8 = $this->peg_currPos; - $this->peg_silentFails++; - $s9 = $this->peg_parseBlock(); - $this->peg_silentFails--; - if ($s9 === $this->peg_FAILED) { - $s8 = null; - } else { - $this->peg_currPos = $s8; - $s8 = $this->peg_FAILED; - } - if ($s8 !== $this->peg_FAILED) { - if ($this->input_length > $this->peg_currPos) { - $s9 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s9 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c0); - } - } - if ($s9 !== $this->peg_FAILED) { - $s8 = array($s8, $s9); - $s7 = $s8; - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - } - if ($s6 !== $this->peg_FAILED) { - $s5 = $this->input_substr($s5, $this->peg_currPos - $s5); - } else { - $s5 = $s6; - } - if ($s5 !== $this->peg_FAILED) { - $this->peg_reportedPos = $s3; - $s4 = $this->peg_f0($s1, $s4, $s5); - $s3 = $s4; - } else { - $this->peg_currPos = $s3; - $s3 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s3; - $s3 = $this->peg_FAILED; - } - } - if ($s2 !== $this->peg_FAILED) { - $s3 = $this->peg_currPos; - $s4 = array(); - if ($this->input_length > $this->peg_currPos) { - $s5 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s5 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c0); - } - } - while ($s5 !== $this->peg_FAILED) { - $s4[] = $s5; - if ($this->input_length > $this->peg_currPos) { - $s5 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s5 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c0); - } - } - } - if ($s4 !== $this->peg_FAILED) { - $s3 = $this->input_substr($s3, $this->peg_currPos - $s3); - } else { - $s3 = $s4; - } - if ($s3 !== $this->peg_FAILED) { - $this->peg_reportedPos = $s0; - $s1 = $this->peg_f1($s1, $s2, $s3); - $s0 = $s1; - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - - return $s0; - } - - private function peg_parseBlock() { - - $s0 = $this->peg_parseBlock_Void(); - if ($s0 === $this->peg_FAILED) { - $s0 = $this->peg_parseBlock_Balanced(); - } - - return $s0; - } - - private function peg_parseBlock_Void() { - - $s0 = $this->peg_currPos; - if ($this->input_substr($this->peg_currPos, 4) === $this->peg_c1) { - $s1 = $this->peg_c1; - $this->peg_currPos += 4; - } else { - $s1 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c2); - } - } - if ($s1 !== $this->peg_FAILED) { - $s2 = $this->peg_parse__(); - if ($s2 !== $this->peg_FAILED) { - if ($this->input_substr($this->peg_currPos, 3) === $this->peg_c3) { - $s3 = $this->peg_c3; - $this->peg_currPos += 3; - } else { - $s3 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c4); - } - } - if ($s3 !== $this->peg_FAILED) { - $s4 = $this->peg_parseBlock_Name(); - if ($s4 !== $this->peg_FAILED) { - $s5 = $this->peg_parse__(); - if ($s5 !== $this->peg_FAILED) { - $s6 = $this->peg_currPos; - $s7 = $this->peg_parseBlock_Attributes(); - if ($s7 !== $this->peg_FAILED) { - $s8 = $this->peg_parse__(); - if ($s8 !== $this->peg_FAILED) { - $this->peg_reportedPos = $s6; - $s7 = $this->peg_f2($s4, $s7); - $s6 = $s7; - } else { - $this->peg_currPos = $s6; - $s6 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s6; - $s6 = $this->peg_FAILED; - } - if ($s6 === $this->peg_FAILED) { - $s6 = null; - } - if ($s6 !== $this->peg_FAILED) { - if ($this->input_substr($this->peg_currPos, 4) === $this->peg_c5) { - $s7 = $this->peg_c5; - $this->peg_currPos += 4; - } else { - $s7 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c6); - } - } - if ($s7 !== $this->peg_FAILED) { - $this->peg_reportedPos = $s0; - $s1 = $this->peg_f3($s4, $s6); - $s0 = $s1; - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - - return $s0; - } - - private function peg_parseBlock_Balanced() { - - $s0 = $this->peg_currPos; - $s1 = $this->peg_parseBlock_Start(); - if ($s1 !== $this->peg_FAILED) { - $s2 = array(); - $s3 = $this->peg_parseBlock(); - if ($s3 === $this->peg_FAILED) { - $s3 = $this->peg_currPos; - $s4 = array(); - $s5 = $this->peg_currPos; - $s6 = $this->peg_currPos; - $this->peg_silentFails++; - $s7 = $this->peg_parseBlock(); - $this->peg_silentFails--; - if ($s7 === $this->peg_FAILED) { - $s6 = null; - } else { - $this->peg_currPos = $s6; - $s6 = $this->peg_FAILED; - } - if ($s6 !== $this->peg_FAILED) { - $s7 = $this->peg_currPos; - $this->peg_silentFails++; - $s8 = $this->peg_parseBlock_End(); - $this->peg_silentFails--; - if ($s8 === $this->peg_FAILED) { - $s7 = null; - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - if ($s7 !== $this->peg_FAILED) { - if ($this->input_length > $this->peg_currPos) { - $s8 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s8 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c0); - } - } - if ($s8 !== $this->peg_FAILED) { - $s6 = array($s6, $s7, $s8); - $s5 = $s6; - } else { - $this->peg_currPos = $s5; - $s5 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s5; - $s5 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s5; - $s5 = $this->peg_FAILED; - } - if ($s5 !== $this->peg_FAILED) { - while ($s5 !== $this->peg_FAILED) { - $s4[] = $s5; - $s5 = $this->peg_currPos; - $s6 = $this->peg_currPos; - $this->peg_silentFails++; - $s7 = $this->peg_parseBlock(); - $this->peg_silentFails--; - if ($s7 === $this->peg_FAILED) { - $s6 = null; - } else { - $this->peg_currPos = $s6; - $s6 = $this->peg_FAILED; - } - if ($s6 !== $this->peg_FAILED) { - $s7 = $this->peg_currPos; - $this->peg_silentFails++; - $s8 = $this->peg_parseBlock_End(); - $this->peg_silentFails--; - if ($s8 === $this->peg_FAILED) { - $s7 = null; - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - if ($s7 !== $this->peg_FAILED) { - if ($this->input_length > $this->peg_currPos) { - $s8 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s8 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c0); - } - } - if ($s8 !== $this->peg_FAILED) { - $s6 = array($s6, $s7, $s8); - $s5 = $s6; - } else { - $this->peg_currPos = $s5; - $s5 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s5; - $s5 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s5; - $s5 = $this->peg_FAILED; - } - } - } else { - $s4 = $this->peg_FAILED; - } - if ($s4 !== $this->peg_FAILED) { - $s3 = $this->input_substr($s3, $this->peg_currPos - $s3); - } else { - $s3 = $s4; - } - } - while ($s3 !== $this->peg_FAILED) { - $s2[] = $s3; - $s3 = $this->peg_parseBlock(); - if ($s3 === $this->peg_FAILED) { - $s3 = $this->peg_currPos; - $s4 = array(); - $s5 = $this->peg_currPos; - $s6 = $this->peg_currPos; - $this->peg_silentFails++; - $s7 = $this->peg_parseBlock(); - $this->peg_silentFails--; - if ($s7 === $this->peg_FAILED) { - $s6 = null; - } else { - $this->peg_currPos = $s6; - $s6 = $this->peg_FAILED; - } - if ($s6 !== $this->peg_FAILED) { - $s7 = $this->peg_currPos; - $this->peg_silentFails++; - $s8 = $this->peg_parseBlock_End(); - $this->peg_silentFails--; - if ($s8 === $this->peg_FAILED) { - $s7 = null; - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - if ($s7 !== $this->peg_FAILED) { - if ($this->input_length > $this->peg_currPos) { - $s8 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s8 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c0); - } - } - if ($s8 !== $this->peg_FAILED) { - $s6 = array($s6, $s7, $s8); - $s5 = $s6; - } else { - $this->peg_currPos = $s5; - $s5 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s5; - $s5 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s5; - $s5 = $this->peg_FAILED; - } - if ($s5 !== $this->peg_FAILED) { - while ($s5 !== $this->peg_FAILED) { - $s4[] = $s5; - $s5 = $this->peg_currPos; - $s6 = $this->peg_currPos; - $this->peg_silentFails++; - $s7 = $this->peg_parseBlock(); - $this->peg_silentFails--; - if ($s7 === $this->peg_FAILED) { - $s6 = null; - } else { - $this->peg_currPos = $s6; - $s6 = $this->peg_FAILED; - } - if ($s6 !== $this->peg_FAILED) { - $s7 = $this->peg_currPos; - $this->peg_silentFails++; - $s8 = $this->peg_parseBlock_End(); - $this->peg_silentFails--; - if ($s8 === $this->peg_FAILED) { - $s7 = null; - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - if ($s7 !== $this->peg_FAILED) { - if ($this->input_length > $this->peg_currPos) { - $s8 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s8 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c0); - } - } - if ($s8 !== $this->peg_FAILED) { - $s6 = array($s6, $s7, $s8); - $s5 = $s6; - } else { - $this->peg_currPos = $s5; - $s5 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s5; - $s5 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s5; - $s5 = $this->peg_FAILED; - } - } - } else { - $s4 = $this->peg_FAILED; - } - if ($s4 !== $this->peg_FAILED) { - $s3 = $this->input_substr($s3, $this->peg_currPos - $s3); - } else { - $s3 = $s4; - } - } - } - if ($s2 !== $this->peg_FAILED) { - $s3 = $this->peg_parseBlock_End(); - if ($s3 !== $this->peg_FAILED) { - $this->peg_reportedPos = $s0; - $s1 = $this->peg_f4($s1, $s2, $s3); - $s0 = $s1; - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - - return $s0; - } - - private function peg_parseBlock_Start() { - - $s0 = $this->peg_currPos; - if ($this->input_substr($this->peg_currPos, 4) === $this->peg_c1) { - $s1 = $this->peg_c1; - $this->peg_currPos += 4; - } else { - $s1 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c2); - } - } - if ($s1 !== $this->peg_FAILED) { - $s2 = $this->peg_parse__(); - if ($s2 !== $this->peg_FAILED) { - if ($this->input_substr($this->peg_currPos, 3) === $this->peg_c3) { - $s3 = $this->peg_c3; - $this->peg_currPos += 3; - } else { - $s3 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c4); - } - } - if ($s3 !== $this->peg_FAILED) { - $s4 = $this->peg_parseBlock_Name(); - if ($s4 !== $this->peg_FAILED) { - $s5 = $this->peg_parse__(); - if ($s5 !== $this->peg_FAILED) { - $s6 = $this->peg_currPos; - $s7 = $this->peg_parseBlock_Attributes(); - if ($s7 !== $this->peg_FAILED) { - $s8 = $this->peg_parse__(); - if ($s8 !== $this->peg_FAILED) { - $this->peg_reportedPos = $s6; - $s7 = $this->peg_f2($s4, $s7); - $s6 = $s7; - } else { - $this->peg_currPos = $s6; - $s6 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s6; - $s6 = $this->peg_FAILED; - } - if ($s6 === $this->peg_FAILED) { - $s6 = null; - } - if ($s6 !== $this->peg_FAILED) { - if ($this->input_substr($this->peg_currPos, 3) === $this->peg_c7) { - $s7 = $this->peg_c7; - $this->peg_currPos += 3; - } else { - $s7 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c8); - } - } - if ($s7 !== $this->peg_FAILED) { - $this->peg_reportedPos = $s0; - $s1 = $this->peg_f5($s4, $s6); - $s0 = $s1; - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - - return $s0; - } - - private function peg_parseBlock_End() { - - $s0 = $this->peg_currPos; - if ($this->input_substr($this->peg_currPos, 4) === $this->peg_c1) { - $s1 = $this->peg_c1; - $this->peg_currPos += 4; - } else { - $s1 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c2); - } - } - if ($s1 !== $this->peg_FAILED) { - $s2 = $this->peg_parse__(); - if ($s2 !== $this->peg_FAILED) { - if ($this->input_substr($this->peg_currPos, 4) === $this->peg_c9) { - $s3 = $this->peg_c9; - $this->peg_currPos += 4; - } else { - $s3 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c10); - } - } - if ($s3 !== $this->peg_FAILED) { - $s4 = $this->peg_parseBlock_Name(); - if ($s4 !== $this->peg_FAILED) { - $s5 = $this->peg_parse__(); - if ($s5 !== $this->peg_FAILED) { - if ($this->input_substr($this->peg_currPos, 3) === $this->peg_c7) { - $s6 = $this->peg_c7; - $this->peg_currPos += 3; - } else { - $s6 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c8); - } - } - if ($s6 !== $this->peg_FAILED) { - $this->peg_reportedPos = $s0; - $s1 = $this->peg_f6($s4); - $s0 = $s1; - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s0; - $s0 = $this->peg_FAILED; - } - - return $s0; - } - - private function peg_parseBlock_Name() { - - $s0 = $this->peg_parseNamespaced_Block_Name(); - if ($s0 === $this->peg_FAILED) { - $s0 = $this->peg_parseCore_Block_Name(); - } - - return $s0; - } - - private function peg_parseNamespaced_Block_Name() { - - $s0 = $this->peg_currPos; - $s1 = $this->peg_currPos; - $s2 = $this->peg_parseBlock_Name_Part(); - if ($s2 !== $this->peg_FAILED) { - if ($this->input_substr($this->peg_currPos, 1) === $this->peg_c11) { - $s3 = $this->peg_c11; - $this->peg_currPos++; - } else { - $s3 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c12); - } - } - if ($s3 !== $this->peg_FAILED) { - $s4 = $this->peg_parseBlock_Name_Part(); - if ($s4 !== $this->peg_FAILED) { - $s2 = array($s2, $s3, $s4); - $s1 = $s2; - } else { - $this->peg_currPos = $s1; - $s1 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s1; - $s1 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s1; - $s1 = $this->peg_FAILED; - } - if ($s1 !== $this->peg_FAILED) { - $s0 = $this->input_substr($s0, $this->peg_currPos - $s0); - } else { - $s0 = $s1; - } - - return $s0; - } - - private function peg_parseCore_Block_Name() { - - $s0 = $this->peg_currPos; - $s1 = $this->peg_currPos; - $s2 = $this->peg_parseBlock_Name_Part(); - if ($s2 !== $this->peg_FAILED) { - $s1 = $this->input_substr($s1, $this->peg_currPos - $s1); - } else { - $s1 = $s2; - } - if ($s1 !== $this->peg_FAILED) { - $this->peg_reportedPos = $s0; - $s1 = $this->peg_f7($s1); - } - $s0 = $s1; - - return $s0; - } - - private function peg_parseBlock_Name_Part() { - - $s0 = $this->peg_currPos; - $s1 = $this->peg_currPos; - if (Gutenberg_PEG_peg_char_class_test($this->peg_c13, $this->input_substr($this->peg_currPos, 1))) { - $s2 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s2 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c14); - } - } - if ($s2 !== $this->peg_FAILED) { - $s3 = array(); - if (Gutenberg_PEG_peg_char_class_test($this->peg_c15, $this->input_substr($this->peg_currPos, 1))) { - $s4 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s4 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c16); - } - } - while ($s4 !== $this->peg_FAILED) { - $s3[] = $s4; - if (Gutenberg_PEG_peg_char_class_test($this->peg_c15, $this->input_substr($this->peg_currPos, 1))) { - $s4 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s4 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c16); - } - } - } - if ($s3 !== $this->peg_FAILED) { - $s2 = array($s2, $s3); - $s1 = $s2; - } else { - $this->peg_currPos = $s1; - $s1 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s1; - $s1 = $this->peg_FAILED; - } - if ($s1 !== $this->peg_FAILED) { - $s0 = $this->input_substr($s0, $this->peg_currPos - $s0); - } else { - $s0 = $s1; - } - - return $s0; - } - - private function peg_parseBlock_Attributes() { - - $this->peg_silentFails++; - $s0 = $this->peg_currPos; - $s1 = $this->peg_currPos; - $s2 = $this->peg_currPos; - if ($this->input_substr($this->peg_currPos, 1) === $this->peg_c18) { - $s3 = $this->peg_c18; - $this->peg_currPos++; - } else { - $s3 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c19); - } - } - if ($s3 !== $this->peg_FAILED) { - $s4 = array(); - $s5 = $this->peg_currPos; - $s6 = $this->peg_currPos; - $this->peg_silentFails++; - $s7 = $this->peg_currPos; - if ($this->input_substr($this->peg_currPos, 1) === $this->peg_c20) { - $s8 = $this->peg_c20; - $this->peg_currPos++; - } else { - $s8 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c21); - } - } - if ($s8 !== $this->peg_FAILED) { - $s9 = $this->peg_parse__(); - if ($s9 !== $this->peg_FAILED) { - $s10 = $this->peg_c22; - if ($s10 !== $this->peg_FAILED) { - if ($this->input_substr($this->peg_currPos, 1) === $this->peg_c11) { - $s11 = $this->peg_c11; - $this->peg_currPos++; - } else { - $s11 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c12); - } - } - if ($s11 === $this->peg_FAILED) { - $s11 = null; - } - if ($s11 !== $this->peg_FAILED) { - if ($this->input_substr($this->peg_currPos, 3) === $this->peg_c7) { - $s12 = $this->peg_c7; - $this->peg_currPos += 3; - } else { - $s12 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c8); - } - } - if ($s12 !== $this->peg_FAILED) { - $s8 = array($s8, $s9, $s10, $s11, $s12); - $s7 = $s8; - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - $this->peg_silentFails--; - if ($s7 === $this->peg_FAILED) { - $s6 = null; - } else { - $this->peg_currPos = $s6; - $s6 = $this->peg_FAILED; - } - if ($s6 !== $this->peg_FAILED) { - if ($this->input_length > $this->peg_currPos) { - $s7 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s7 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c0); - } - } - if ($s7 !== $this->peg_FAILED) { - $s6 = array($s6, $s7); - $s5 = $s6; - } else { - $this->peg_currPos = $s5; - $s5 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s5; - $s5 = $this->peg_FAILED; - } - while ($s5 !== $this->peg_FAILED) { - $s4[] = $s5; - $s5 = $this->peg_currPos; - $s6 = $this->peg_currPos; - $this->peg_silentFails++; - $s7 = $this->peg_currPos; - if ($this->input_substr($this->peg_currPos, 1) === $this->peg_c20) { - $s8 = $this->peg_c20; - $this->peg_currPos++; - } else { - $s8 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c21); - } - } - if ($s8 !== $this->peg_FAILED) { - $s9 = $this->peg_parse__(); - if ($s9 !== $this->peg_FAILED) { - $s10 = $this->peg_c22; - if ($s10 !== $this->peg_FAILED) { - if ($this->input_substr($this->peg_currPos, 1) === $this->peg_c11) { - $s11 = $this->peg_c11; - $this->peg_currPos++; - } else { - $s11 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c12); - } - } - if ($s11 === $this->peg_FAILED) { - $s11 = null; - } - if ($s11 !== $this->peg_FAILED) { - if ($this->input_substr($this->peg_currPos, 3) === $this->peg_c7) { - $s12 = $this->peg_c7; - $this->peg_currPos += 3; - } else { - $s12 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c8); - } - } - if ($s12 !== $this->peg_FAILED) { - $s8 = array($s8, $s9, $s10, $s11, $s12); - $s7 = $s8; - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s7; - $s7 = $this->peg_FAILED; - } - $this->peg_silentFails--; - if ($s7 === $this->peg_FAILED) { - $s6 = null; - } else { - $this->peg_currPos = $s6; - $s6 = $this->peg_FAILED; - } - if ($s6 !== $this->peg_FAILED) { - if ($this->input_length > $this->peg_currPos) { - $s7 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s7 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c0); - } - } - if ($s7 !== $this->peg_FAILED) { - $s6 = array($s6, $s7); - $s5 = $s6; - } else { - $this->peg_currPos = $s5; - $s5 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s5; - $s5 = $this->peg_FAILED; - } - } - if ($s4 !== $this->peg_FAILED) { - if ($this->input_substr($this->peg_currPos, 1) === $this->peg_c20) { - $s5 = $this->peg_c20; - $this->peg_currPos++; - } else { - $s5 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c21); - } - } - if ($s5 !== $this->peg_FAILED) { - $s3 = array($s3, $s4, $s5); - $s2 = $s3; - } else { - $this->peg_currPos = $s2; - $s2 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s2; - $s2 = $this->peg_FAILED; - } - } else { - $this->peg_currPos = $s2; - $s2 = $this->peg_FAILED; - } - if ($s2 !== $this->peg_FAILED) { - $s1 = $this->input_substr($s1, $this->peg_currPos - $s1); - } else { - $s1 = $s2; - } - if ($s1 !== $this->peg_FAILED) { - $this->peg_reportedPos = $s0; - $s1 = $this->peg_f8($s1); - } - $s0 = $s1; - $this->peg_silentFails--; - if ($s0 === $this->peg_FAILED) { - $s1 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c17); - } - } - - return $s0; - } - - private function peg_parse__() { - - $s0 = array(); - if (Gutenberg_PEG_peg_char_class_test($this->peg_c23, $this->input_substr($this->peg_currPos, 1))) { - $s1 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s1 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c24); - } - } - if ($s1 !== $this->peg_FAILED) { - while ($s1 !== $this->peg_FAILED) { - $s0[] = $s1; - if (Gutenberg_PEG_peg_char_class_test($this->peg_c23, $this->input_substr($this->peg_currPos, 1))) { - $s1 = $this->input_substr($this->peg_currPos, 1); - $this->peg_currPos++; - } else { - $s1 = $this->peg_FAILED; - if ($this->peg_silentFails === 0) { - $this->peg_fail($this->peg_c24); - } - } - } - } else { - $s0 = $this->peg_FAILED; - } - - return $s0; - } - - public function parse($input) { - $arguments = func_get_args(); - $options = count($arguments) > 1 ? $arguments[1] : array(); - $this->cleanup_state(); - - if (is_array($input)) { - $this->input = $input; - } else { - preg_match_all("/./us", $input, $match); - $this->input = $match[0]; - } - $this->input_length = count($this->input); - - $this->peg_FAILED = new stdClass; - $this->peg_c0 = array("type" => "any", "description" => "any character" ); - $this->peg_c1 = ""; - $this->peg_c6 = array( "type" => "literal", "value" => "/-->", "description" => "\"/-->\"" ); - $this->peg_c7 = "-->"; - $this->peg_c8 = array( "type" => "literal", "value" => "-->", "description" => "\"-->\"" ); - $this->peg_c9 = "/wp:"; - $this->peg_c10 = array( "type" => "literal", "value" => "/wp:", "description" => "\"/wp:\"" ); - $this->peg_c11 = "/"; - $this->peg_c12 = array( "type" => "literal", "value" => "/", "description" => "\"/\"" ); - $this->peg_c13 = array(array(97,122)); - $this->peg_c14 = array( "type" => "class", "value" => "[a-z]", "description" => "[a-z]" ); - $this->peg_c15 = array(array(97,122), array(48,57), array(95,95), array(45,45)); - $this->peg_c16 = array( "type" => "class", "value" => "[a-z0-9_-]", "description" => "[a-z0-9_-]" ); - $this->peg_c17 = array("type" => "other", "description" => "JSON-encoded attributes embedded in a block's opening comment" ); - $this->peg_c18 = "{"; - $this->peg_c19 = array( "type" => "literal", "value" => "{", "description" => "\"{\"" ); - $this->peg_c20 = "}"; - $this->peg_c21 = array( "type" => "literal", "value" => "}", "description" => "\"}\"" ); - $this->peg_c22 = ""; - $this->peg_c23 = array(array(32,32), array(9,9), array(13,13), array(10,10)); - $this->peg_c24 = array( "type" => "class", "value" => "[ \t\r\n]", "description" => "[ \t\r\n]" ); - - $peg_startRuleFunctions = array( 'Block_List' => array($this, "peg_parseBlock_List") ); - $peg_startRuleFunction = array($this, "peg_parseBlock_List"); - if (isset($options["startRule"])) { - if (!(isset($peg_startRuleFunctions[$options["startRule"]]))) { - throw new Exception("Can't start parsing from rule \"" + $options["startRule"] + "\"."); - } - - $peg_startRuleFunction = $peg_startRuleFunctions[$options["startRule"]]; - } - - /* BEGIN initializer code */ - - // 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; - } - } - - - /* END initializer code */ - - $peg_result = call_user_func($peg_startRuleFunction); - - if ($peg_result !== $this->peg_FAILED && $this->peg_currPos === $this->input_length) { - $this->cleanup_state(); // Free up memory - return $peg_result; - } else { - if ($peg_result !== $this->peg_FAILED && $this->peg_currPos < $this->input_length) { - $this->peg_fail(array("type" => "end", "description" => "end of input" )); - } - - $exception = $this->peg_buildException(null, $this->peg_maxFailExpected, $this->peg_maxFailPos); - $this->cleanup_state(); // Free up memory - throw $exception; - } - } - -}; \ No newline at end of file diff --git a/lib/plugin-compat.php b/lib/plugin-compat.php deleted file mode 100644 index 963535624de4c3..00000000000000 --- a/lib/plugin-compat.php +++ /dev/null @@ -1,34 +0,0 @@ - tags. This adds a filter prior to saving the post via - * REST API to disable markdown support. Disables markdown support provided by - * plugins Jetpack, JP-Markdown, and WP Editor.MD - * - * @since 1.3.0 - * - * @param array $post Post object which contains content to check for block. - * @return array $post Post object. - */ -function gutenberg_remove_wpcom_markdown_support( $post ) { - if ( class_exists( 'WPCom_Markdown' ) && has_blocks( $post['post_content'] ) ) { - WPCom_Markdown::get_instance()->unload_markdown_for_posts(); - } - return $post; -} -add_filter( 'wp_insert_post_data', 'gutenberg_remove_wpcom_markdown_support', 9 ); diff --git a/lib/register.php b/lib/register.php deleted file mode 100644 index b86df4fd7a37f0..00000000000000 --- a/lib/register.php +++ /dev/null @@ -1,633 +0,0 @@ -post_type; - - if ( ! gutenberg_can_edit_post( $post->ID ) ) { - return; - } - } else { - // Eventually add handling for creating new posts of different types in Gutenberg. - } - $post_type = $post->post_type; - $post_type_object = get_post_type_object( $post_type ); - - if ( ! gutenberg_can_edit_post_type( $post_type ) ) { - return; - } - - // Disable hidden metaboxes because there's no UI to toggle visibility. - add_filter( 'hidden_meta_boxes', '__return_empty_array' ); - - $thumbnail_support = current_theme_supports( 'post-thumbnails', $post_type ) && post_type_supports( $post_type, 'thumbnail' ); - if ( ! $thumbnail_support && 'attachment' === $post_type && $post->post_mime_type ) { - if ( wp_attachment_is( 'audio', $post ) ) { - $thumbnail_support = post_type_supports( 'attachment:audio', 'thumbnail' ) || current_theme_supports( 'post-thumbnails', 'attachment:audio' ); - } elseif ( wp_attachment_is( 'video', $post ) ) { - $thumbnail_support = post_type_supports( 'attachment:video', 'thumbnail' ) || current_theme_supports( 'post-thumbnails', 'attachment:video' ); - } - } - - /* - * WIP: Collect and send information needed to render meta boxes. - * From wp-admin/edit-form-advanced.php - * Relevant code there: - * do_action( 'do_meta_boxes', $post_type, {'normal','advanced','side'}, $post ); - * do_meta_boxes( $post_type, 'side', $post ); - * do_meta_boxes( null, 'normal', $post ); - * do_meta_boxes( null, 'advanced', $post ); - */ - $publish_callback_args = null; - if ( post_type_supports( $post_type, 'revisions' ) && 'auto-draft' !== $post->post_status ) { - $revisions = wp_get_post_revisions( $post->ID ); - - // We should aim to show the revisions meta box only when there are revisions. - if ( count( $revisions ) > 1 ) { - reset( $revisions ); // Reset pointer for key(). - $publish_callback_args = array( - 'revisions_count' => count( $revisions ), - 'revision_id' => key( $revisions ), - ); - add_meta_box( 'revisionsdiv', __( 'Revisions', 'gutenberg' ), 'post_revisions_meta_box', $screen, 'normal', 'core' ); - } - } - - if ( 'attachment' == $post_type ) { - wp_enqueue_script( 'image-edit' ); - wp_enqueue_style( 'imgareaselect' ); - add_meta_box( 'submitdiv', __( 'Save', 'gutenberg' ), 'attachment_submit_meta_box', $screen, 'side', 'core' ); - add_action( 'edit_form_after_title', 'edit_form_image_editor' ); - - if ( wp_attachment_is( 'audio', $post ) ) { - add_meta_box( 'attachment-id3', __( 'Metadata', 'gutenberg' ), 'attachment_id3_data_meta_box', $screen, 'normal', 'core' ); - } - } else { - add_meta_box( 'submitdiv', __( 'Publish', 'gutenberg' ), 'post_submit_meta_box', $screen, 'side', 'core', $publish_callback_args ); - } - - if ( current_theme_supports( 'post-formats' ) && post_type_supports( $post_type, 'post-formats' ) ) { - add_meta_box( 'formatdiv', _x( 'Format', 'post format', 'gutenberg' ), 'post_format_meta_box', $screen, 'side', 'core' ); - } - - // All taxonomies. - foreach ( get_object_taxonomies( $post ) as $tax_name ) { - $taxonomy = get_taxonomy( $tax_name ); - if ( ! $taxonomy->show_ui || false === $taxonomy->meta_box_cb ) { - continue; - } - - $label = $taxonomy->labels->name; - - if ( ! is_taxonomy_hierarchical( $tax_name ) ) { - $tax_meta_box_id = 'tagsdiv-' . $tax_name; - } else { - $tax_meta_box_id = $tax_name . 'div'; - } - - add_meta_box( $tax_meta_box_id, $label, $taxonomy->meta_box_cb, $screen, 'side', 'core', array( 'taxonomy' => $tax_name ) ); - } - - if ( post_type_supports( $post_type, 'page-attributes' ) || count( get_page_templates( $post ) ) > 0 ) { - add_meta_box( 'pageparentdiv', $post_type_object->labels->attributes, 'page_attributes_meta_box', $screen, 'side', 'core' ); - } - - if ( $thumbnail_support && current_user_can( 'upload_files' ) ) { - add_meta_box( 'postimagediv', esc_html( $post_type_object->labels->featured_image ), 'post_thumbnail_meta_box', $screen, 'side', 'low' ); - } - - if ( post_type_supports( $post_type, 'excerpt' ) ) { - add_meta_box( 'postexcerpt', __( 'Excerpt', 'gutenberg' ), 'post_excerpt_meta_box', $screen, 'normal', 'core' ); - } - - if ( post_type_supports( $post_type, 'trackbacks' ) ) { - add_meta_box( 'trackbacksdiv', __( 'Send Trackbacks', 'gutenberg' ), 'post_trackback_meta_box', $screen, 'normal', 'core' ); - } - - if ( post_type_supports( $post_type, 'custom-fields' ) ) { - add_meta_box( 'postcustom', __( 'Custom Fields', 'gutenberg' ), 'post_custom_meta_box', $screen, 'normal', 'core' ); - } - - /** - * Fires in the middle of built-in meta box registration. - * - * @since 2.1.0 - * @deprecated 3.7.0 Use 'add_meta_boxes' instead. - * - * @param WP_Post $post Post object. - */ - do_action( 'dbx_post_advanced', $post ); - - // Allow the Discussion meta box to show up if the post type supports comments, - // or if comments or pings are open. - if ( comments_open( $post ) || pings_open( $post ) || post_type_supports( $post_type, 'comments' ) ) { - add_meta_box( 'commentstatusdiv', __( 'Discussion', 'gutenberg' ), 'post_comment_status_meta_box', $screen, 'normal', 'core' ); - } - - $stati = get_post_stati( array( 'public' => true ) ); - if ( empty( $stati ) ) { - $stati = array( 'publish' ); - } - $stati[] = 'private'; - - if ( in_array( get_post_status( $post ), $stati ) ) { - // If the post type support comments, or the post has comments, allow the - // Comments meta box. - if ( comments_open( $post ) || pings_open( $post ) || $post->comment_count > 0 || post_type_supports( $post_type, 'comments' ) ) { - add_meta_box( 'commentsdiv', __( 'Comments', 'gutenberg' ), 'post_comment_meta_box', $screen, 'normal', 'core' ); - } - } - - if ( ! ( 'pending' == get_post_status( $post ) && ! current_user_can( $post_type_object->cap->publish_posts ) ) ) { - add_meta_box( 'slugdiv', __( 'Slug', 'gutenberg' ), 'post_slug_meta_box', $screen, 'normal', 'core' ); - } - - if ( post_type_supports( $post_type, 'author' ) && current_user_can( $post_type_object->cap->edit_others_posts ) ) { - add_meta_box( 'authordiv', __( 'Author', 'gutenberg' ), 'post_author_meta_box', $screen, 'normal', 'core' ); - } - - // Run the hooks for adding meta boxes for a specific post type. - do_action( 'add_meta_boxes', $post_type, $post ); - do_action( "add_meta_boxes_{$post_type}", $post ); - - // Set up meta box locations. - $locations = array( 'normal', 'advanced', 'side' ); - - // Foreach location run the hooks meta boxes are potentially registered on. - foreach ( $locations as $location ) { - do_action( - 'do_meta_boxes', - $screen, - $location, - $post - ); - } - do_action( 'edit_form_advanced', $post ); - - // Copy meta box state. - $_meta_boxes_copy = $wp_meta_boxes; - - /** - * Documented in lib/meta-box-partial-page.php - * - * @param array $wp_meta_boxes Global meta box state. - */ - $_meta_boxes_copy = apply_filters( 'filter_gutenberg_meta_boxes', $_meta_boxes_copy ); - - // Redirect to classic editor if a meta box is incompatible. - foreach ( $locations as $location ) { - if ( ! isset( $_meta_boxes_copy[ $post->post_type ][ $location ] ) ) { - continue; - } - // Check if we have a meta box that has declared itself incompatible with the block editor. - foreach ( $_meta_boxes_copy[ $post->post_type ][ $location ] as $boxes ) { - foreach ( $boxes as $box ) { - /* - * If __block_editor_compatible_meta_box is declared as a false-y value, - * the meta box is not compatible with the block editor. - */ - if ( is_array( $box['args'] ) - && isset( $box['args']['__block_editor_compatible_meta_box'] ) - && ! $box['args']['__block_editor_compatible_meta_box'] ) { - $incompatible_meta_box = true; - ?> - - post_status ) { - $can_edit = false; - } - - if ( $can_edit && ! gutenberg_can_edit_post_type( $post->post_type ) ) { - $can_edit = false; - } - - if ( $can_edit && ! current_user_can( 'edit_post', $post->ID ) ) { - $can_edit = false; - } - - // Disable the editor if on the blog page and there is no content. - if ( $can_edit && absint( get_option( 'page_for_posts' ) ) === $post->ID && empty( $post->post_content ) ) { - $can_edit = false; - } - - /** - * Filter to allow plugins to enable/disable Gutenberg for particular post. - * - * @since 3.5 - * - * @param bool $can_edit Whether the post can be edited or not. - * @param WP_Post $post The post being checked. - */ - return apply_filters( 'gutenberg_can_edit_post', $can_edit, $post ); - -} - -/** - * Return whether the post type can be edited in Gutenberg. - * - * Gutenberg depends on the REST API, and if the post type is not shown in the - * REST API, then the post cannot be edited in Gutenberg. - * - * @since 1.5.2 - * - * @param string $post_type The post type. - * @return bool Whether the post type can be edited with Gutenberg. - */ -function gutenberg_can_edit_post_type( $post_type ) { - $can_edit = true; - if ( ! post_type_exists( $post_type ) ) { - $can_edit = false; - } - - if ( ! post_type_supports( $post_type, 'editor' ) ) { - $can_edit = false; - } - - $post_type_object = get_post_type_object( $post_type ); - if ( $post_type_object && ! $post_type_object->show_in_rest ) { - $can_edit = false; - } - - /** - * Filter to allow plugins to enable/disable Gutenberg for particular post types. - * - * @since 1.5.2 - * - * @param bool $can_edit Whether the post type can be edited or not. - * @param string $post_type The post type being checked. - */ - return apply_filters( 'gutenberg_can_edit_post_type', $can_edit, $post_type ); -} - -if ( ! function_exists( 'has_blocks' ) ) { - /** - * Determine whether a post or content string has blocks. - * - * This test optimizes for performance rather than strict accuracy, detecting - * the pattern of a block but not validating its structure. For strict accuracy - * you should use the block parser on post content. - * - * @since 3.6.0 - * @see gutenberg_parse_blocks() - * - * @param int|string|WP_Post|null $post Optional. Post content, post ID, or post object. Defaults to global $post. - * @return bool Whether the post has blocks. - */ - function has_blocks( $post = null ) { - if ( ! is_string( $post ) ) { - $wp_post = get_post( $post ); - if ( $wp_post instanceof WP_Post ) { - $post = $wp_post->post_content; - } - } - - return false !== strpos( (string) $post, ' -### Usage +### setup -To make the `wp.a11y.speak` functionality more universally available, we've decided to create a dedicated JS module for it, called `speak`. Usage is very simple: +[src/index.js#L16-L26](src/index.js#L16-L26) -```JS +Create the live regions. + +### speak + +[src/index.js#L52-L66](src/index.js#L52-L66) + +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. @@ -30,26 +41,38 @@ speak( 'The message you want to send to the ARIA live region' ); 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. + + + + ### 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 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/ +See

Code is Poetry.

diff --git a/packages/a11y/package.json b/packages/a11y/package.json index 48719ae8f39485..bd564572960a65 100644 --- a/packages/a11y/package.json +++ b/packages/a11y/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/a11y", - "version": "2.0.2", + "version": "2.1.0", "description": "Accessibility (a11y) utilities for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -12,7 +12,8 @@ "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/a11y/README.md", "repository": { "type": "git", - "url": "https://github.com/WordPress/gutenberg.git" + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/a11y" }, "bugs": { "url": "https://github.com/WordPress/gutenberg/issues" @@ -21,7 +22,7 @@ "module": "build-module/index.js", "react-native": "src/index", "dependencies": { - "@babel/runtime": "^7.0.0", + "@babel/runtime": "^7.3.1", "@wordpress/dom-ready": "file:../dom-ready" }, "publishConfig": { diff --git a/packages/a11y/src/index.js b/packages/a11y/src/index.js index 7129b2957e20cf..525ea63b879684 100644 --- a/packages/a11y/src/index.js +++ b/packages/a11y/src/index.js @@ -1,6 +1,13 @@ +/** + * WordPress dependencies + */ +import domReady from '@wordpress/dom-ready'; + +/** + * Internal dependencies + */ import addContainer from './addContainer'; import clear from './clear'; -import domReady from '@wordpress/dom-ready'; import filterMessage from './filterMessage'; /** @@ -24,11 +31,23 @@ export const setup = function() { domReady( setup ); /** - * Update the ARIA live notification area text node. + * 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. diff --git a/packages/a11y/src/test/addContainer.test.js b/packages/a11y/src/test/addContainer.test.js index 7b09cc630ba6d3..e6c722078f74f3 100644 --- a/packages/a11y/src/test/addContainer.test.js +++ b/packages/a11y/src/test/addContainer.test.js @@ -1,3 +1,6 @@ +/** + * Internal dependencies + */ import addContainer from '../addContainer'; describe( 'addContainer', () => { diff --git a/packages/a11y/src/test/clear.test.js b/packages/a11y/src/test/clear.test.js index c139f0d664ab54..02438dda1a4ecb 100644 --- a/packages/a11y/src/test/clear.test.js +++ b/packages/a11y/src/test/clear.test.js @@ -1,3 +1,6 @@ +/** + * Internal dependencies + */ import clear from '../clear'; describe( 'clear', () => { diff --git a/packages/a11y/src/test/filterMessage.test.js b/packages/a11y/src/test/filterMessage.test.js index 3dcdf54e9b18d8..95302abb8d1ce2 100644 --- a/packages/a11y/src/test/filterMessage.test.js +++ b/packages/a11y/src/test/filterMessage.test.js @@ -1,3 +1,6 @@ +/** + * Internal dependencies + */ import filterMessage from '../filterMessage'; describe( 'filterMessage', () => { diff --git a/packages/a11y/src/test/index.test.js b/packages/a11y/src/test/index.test.js index 3737e4e444754d..08c9489bed2ae3 100644 --- a/packages/a11y/src/test/index.test.js +++ b/packages/a11y/src/test/index.test.js @@ -1,4 +1,14 @@ +/** + * 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(); @@ -14,10 +24,6 @@ jest.mock( '../filterMessage', () => { } ); } ); -import clear from '../clear'; -import domReady from '@wordpress/dom-ready'; -import filterMessage from '../filterMessage'; - describe( 'speak', () => { let containerPolite = document.getElementById( 'a11y-speak-polite' ); let containerAssertive = document.getElementById( 'a11y-speak-assertive' ); diff --git a/packages/annotations/CHANGELOG.md b/packages/annotations/CHANGELOG.md index edceba997fe675..cb15ba170bbe41 100644 --- a/packages/annotations/CHANGELOG.md +++ b/packages/annotations/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.0.5 (2019-01-03) + +## 1.0.4 (2018-12-12) + ## 1.0.3 (2018-11-21) ## 1.0.2 (2018-11-20) diff --git a/packages/annotations/README.md b/packages/annotations/README.md index a1585de3106cb1..1c20f8956e5871 100644 --- a/packages/annotations/README.md +++ b/packages/annotations/README.md @@ -13,3 +13,5 @@ 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)._ ## Usage + +

Code is Poetry.

diff --git a/packages/annotations/package.json b/packages/annotations/package.json index 5a73fb5e31ae49..34c055841844fe 100644 --- a/packages/annotations/package.json +++ b/packages/annotations/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/annotations", - "version": "1.0.3", + "version": "1.1.0", "description": "Annotate content in the Gutenberg editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -11,7 +11,8 @@ "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/annotations/README.md", "repository": { "type": "git", - "url": "https://github.com/WordPress/gutenberg.git" + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/annotations" }, "bugs": { "url": "https://github.com/WordPress/gutenberg/issues" @@ -20,12 +21,12 @@ "module": "build-module/index.js", "react-native": "src/index", "dependencies": { - "@babel/runtime": "^7.0.0", + "@babel/runtime": "^7.3.1", "@wordpress/data": "file:../data", "@wordpress/hooks": "file:../hooks", "@wordpress/i18n": "file:../i18n", "@wordpress/rich-text": "file:../rich-text", - "lodash": "^4.17.10", + "lodash": "^4.17.11", "memize": "^1.0.5", "rememo": "^3.0.0", "uuid": "^3.3.2" diff --git a/packages/annotations/src/store/index.js b/packages/annotations/src/store/index.js index 917a342ad9f49d..1c7f27dccbc351 100644 --- a/packages/annotations/src/store/index.js +++ b/packages/annotations/src/store/index.js @@ -1,5 +1,5 @@ /** - * WordPress Dependencies + * WordPress dependencies */ import { registerStore } from '@wordpress/data'; diff --git a/packages/api-fetch/CHANGELOG.md b/packages/api-fetch/CHANGELOG.md index 912d833f3b909e..dc7dcdeb3defea 100644 --- a/packages/api-fetch/CHANGELOG.md +++ b/packages/api-fetch/CHANGELOG.md @@ -1,3 +1,16 @@ +## 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) diff --git a/packages/api-fetch/README.md b/packages/api-fetch/README.md index 85bd65e1be6329..c5b6b190ddbe3d 100644 --- a/packages/api-fetch/README.md +++ b/packages/api-fetch/README.md @@ -76,6 +76,8 @@ 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 @@ -85,4 +87,27 @@ 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, + } ); +} ); +``` +

Code is Poetry.

diff --git a/packages/api-fetch/package.json b/packages/api-fetch/package.json index 3cb6fb3c22979d..67f99e6907d7a6 100644 --- a/packages/api-fetch/package.json +++ b/packages/api-fetch/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/api-fetch", - "version": "2.2.5", + "version": "3.0.0", "description": "Utility to make WordPress REST API requests.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -12,7 +12,8 @@ "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/api-fetch/README.md", "repository": { "type": "git", - "url": "https://github.com/WordPress/gutenberg.git" + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/api-fetch" }, "bugs": { "url": "https://github.com/WordPress/gutenberg/issues" @@ -21,8 +22,7 @@ "module": "build-module/index.js", "react-native": "src/index", "dependencies": { - "@babel/runtime": "^7.0.0", - "@wordpress/hooks": "file:../hooks", + "@babel/runtime": "^7.3.1", "@wordpress/i18n": "file:../i18n", "@wordpress/url": "file:../url" }, diff --git a/packages/api-fetch/src/index.js b/packages/api-fetch/src/index.js index d8e978b05e4641..27fb225fe94eb8 100644 --- a/packages/api-fetch/src/index.js +++ b/packages/api-fetch/src/index.js @@ -38,106 +38,121 @@ const DEFAULT_OPTIONS = { credentials: 'include', }; -const middlewares = []; +const middlewares = [ + userLocaleMiddleware, + namespaceEndpointMiddleware, + httpV1Middleware, + fetchAllMiddleware, +]; function registerMiddleware( middleware ) { - middlewares.push( middleware ); + middlewares.unshift( middleware ); } -function apiFetch( options ) { - const raw = ( nextOptions ) => { - const { url, path, data, parse = true, ...remainingOptions } = nextOptions; - let { body, headers } = nextOptions; +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, + } + ); + const checkStatus = ( response ) => { + if ( response.status >= 200 && response.status < 300 ) { + return response; + } + + throw response; + }; - // Merge explicitly-provided headers with default values. - headers = { ...DEFAULT_HEADERS, ...headers }; + const parseResponse = ( response ) => { + if ( parse ) { + if ( response.status === 204 ) { + return null; + } - // The `data` property is a shorthand for sending a JSON body. - if ( data ) { - body = JSON.stringify( data ); - headers[ 'Content-Type' ] = 'application/json'; + return response.json ? response.json() : Promise.reject( response ); } - const responsePromise = window.fetch( - url || path, - { - ...DEFAULT_OPTIONS, - ...remainingOptions, - body, - headers, - } - ); - const checkStatus = ( response ) => { - if ( response.status >= 200 && response.status < 300 ) { - return response; - } + return response; + }; - throw response; - }; + return responsePromise + .then( checkStatus ) + .then( parseResponse ) + .catch( ( response ) => { + if ( ! parse ) { + throw response; + } - const parseResponse = ( response ) => { - if ( parse ) { - if ( response.status === 204 ) { - return null; - } + const invalidJsonError = { + code: 'invalid_json', + message: __( 'The response is not a valid JSON response.' ), + }; - return response.json ? response.json() : Promise.reject( response ); + if ( ! response || ! response.json ) { + throw invalidJsonError; } - return response; - }; - - return responsePromise - .then( checkStatus ) - .then( parseResponse ) - .catch( ( response ) => { - if ( ! parse ) { - throw response; - } - - const invalidJsonError = { - code: 'invalid_json', - message: __( 'The response is not a valid JSON response.' ), - }; - - if ( ! response || ! response.json ) { + return response.json() + .catch( () => { throw invalidJsonError; - } - - return response.json() - .catch( () => { - throw invalidJsonError; - } ) - .then( ( error ) => { - const unknownError = { - code: 'unknown_error', - message: __( 'An unknown error occurred.' ), - }; - - throw error || unknownError; - } ); - } ); - }; + } ) + .then( ( error ) => { + const unknownError = { + code: 'unknown_error', + message: __( 'An unknown error occurred.' ), + }; + + throw error || unknownError; + } ); + } ); +}; + +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 steps = [ - raw, - fetchAllMiddleware, - httpV1Middleware, - namespaceEndpointMiddleware, - userLocaleMiddleware, - ...middlewares, - ].reverse(); - - const runMiddleware = ( index ) => ( nextOptions ) => { - const nextMiddleware = steps[ index ]; - const next = runMiddleware( index + 1 ); - return nextMiddleware( nextOptions, next ); + const next = createRunStep( index + 1 ); + return step( workingOptions, next ); }; - return runMiddleware( 0 )( options ); + return createRunStep( 0 )( options ); } apiFetch.use = registerMiddleware; +apiFetch.setFetchHandler = setFetchHandler; apiFetch.createNonceMiddleware = createNonceMiddleware; apiFetch.createPreloadingMiddleware = createPreloadingMiddleware; diff --git a/packages/api-fetch/src/middlewares/nonce.js b/packages/api-fetch/src/middlewares/nonce.js index 706715a55a9e09..e9baf2e785b5df 100644 --- a/packages/api-fetch/src/middlewares/nonce.js +++ b/packages/api-fetch/src/middlewares/nonce.js @@ -1,50 +1,27 @@ -/** - * External dependencies - */ -import { addAction } from '@wordpress/hooks'; +function createNonceMiddleware( nonce ) { + function middleware( options, next ) { + const { headers = {} } = options; -const createNonceMiddleware = ( nonce ) => { - let usedNonce = nonce; - - /** - * This is not ideal but it's fine for now. - * - * Configure heartbeat to refresh the wp-api nonce, keeping the editor - * authorization intact. - */ - addAction( 'heartbeat.tick', 'core/api-fetch/create-nonce-middleware', ( response ) => { - if ( response[ 'rest-nonce' ] ) { - usedNonce = response[ 'rest-nonce' ]; - } - } ); - - return function( options, next ) { - let headers = options.headers || {}; // If an 'X-WP-Nonce' header (or any case-insensitive variation // thereof) was specified, no need to add a nonce header. - let addNonceHeader = true; for ( const headerName in headers ) { - if ( headers.hasOwnProperty( headerName ) ) { - if ( headerName.toLowerCase() === 'x-wp-nonce' ) { - addNonceHeader = false; - break; - } + if ( headerName.toLowerCase() === 'x-wp-nonce' ) { + return next( options ); } } - if ( addNonceHeader ) { - // Do not mutate the original headers object, if any. - headers = { - ...headers, - 'X-WP-Nonce': usedNonce, - }; - } - return next( { ...options, - headers, + headers: { + ...headers, + 'X-WP-Nonce': middleware.nonce, + }, } ); - }; -}; + } + + middleware.nonce = nonce; + + return middleware; +} export default createNonceMiddleware; diff --git a/packages/api-fetch/src/middlewares/test/nonce.js b/packages/api-fetch/src/middlewares/test/nonce.js index 7c0dde16845f0d..3ce40bbd55f773 100644 --- a/packages/api-fetch/src/middlewares/test/nonce.js +++ b/packages/api-fetch/src/middlewares/test/nonce.js @@ -31,6 +31,7 @@ describe( 'Nonce middleware', () => { headers: { 'X-WP-Nonce': 'existing nonce' }, }; const callback = ( options ) => { + expect( options ).toBe( requestOptions ); expect( options.headers[ 'X-WP-Nonce' ] ).toBe( 'existing nonce' ); }; diff --git a/packages/api-fetch/src/test/index.js b/packages/api-fetch/src/test/index.js index 88d4f35b09a57b..1becd96d965769 100644 --- a/packages/api-fetch/src/test/index.js +++ b/packages/api-fetch/src/test/index.js @@ -183,4 +183,38 @@ describe( 'apiFetch', () => { } ); } ); } ); + + 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/packages/autop/CHANGELOG.md b/packages/autop/CHANGELOG.md index 04917d2c7de865..53373272a2459d 100644 --- a/packages/autop/CHANGELOG.md +++ b/packages/autop/CHANGELOG.md @@ -1,3 +1,9 @@ +## 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 diff --git a/packages/autop/README.md b/packages/autop/README.md index 4aecd9cafa3de3..ffb9168b63f208 100644 --- a/packages/autop/README.md +++ b/packages/autop/README.md @@ -12,23 +12,61 @@ 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)._ -### Usage +### API -Import the desired function(s) from `@wordpress/autop`: + + +#### autop + +[src/index.js#L129-L285](src/index.js#L129-L285) + +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 `
` tags, unless br is set to 'false'. + +**Usage** ```js -import { autop, removep } from '@wordpress/autop'; +import { autop } from '@wordpress/autop'; +autop( 'my text' ); // "

my text

" +``` + +**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. -autop( 'my text' ); -// "

my text

" +#### removep -removep( '

my text

' ); -// "my text" +[src/index.js#L303-L426](src/index.js#L303-L426) + +Replaces `

` tags with two line breaks. "Opposite" of autop(). + +Replaces `

` tags with two line breaks except where the `

` has attributes. +Unifies whitespace. Indents `

  • `, `
    ` and `
    ` for better readability. + +**Usage** + +```js +import { removep } from '@wordpress/autop'; +removep( '

    my text

    ' ); // "my text" ``` -### API Usage +**Parameters** + +- **html** `string`: The content from the editor. + +**Returns** + +`string`: The content with stripped paragraph tags. + -* `autop( text: string ): string` -* `removep( text: string ): string` +

    Code is Poetry.

    diff --git a/packages/autop/package.json b/packages/autop/package.json index c99aebd3b0f355..4f1441ff2024f8 100644 --- a/packages/autop/package.json +++ b/packages/autop/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/autop", - "version": "2.0.2", + "version": "2.1.0", "description": "WordPress's automatic paragraph functions `autop` and `removep`.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -11,7 +11,8 @@ "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/autop/README.md", "repository": { "type": "git", - "url": "https://github.com/WordPress/gutenberg.git" + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/autop" }, "bugs": { "url": "https://github.com/WordPress/gutenberg/issues" @@ -20,7 +21,7 @@ "module": "build-module/index.js", "react-native": "src/index", "dependencies": { - "@babel/runtime": "^7.0.0" + "@babel/runtime": "^7.3.1" }, "publishConfig": { "access": "public" diff --git a/packages/autop/src/index.js b/packages/autop/src/index.js index 432a567a67a8a6..809a66b412d698 100644 --- a/packages/autop/src/index.js +++ b/packages/autop/src/index.js @@ -112,11 +112,18 @@ function replaceInHtmlTags( haystack, replacePairs ) { * * 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 <
    > tags, unless br is set to 'false'. + * breaks after conversion become `
    ` 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' ); // "

    my text

    " + * ``` + * * @return {string} Text which has been converted into paragraph tags. */ export function autop( text, br = true ) { @@ -162,7 +169,7 @@ export function autop( text, br = true ) { 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' ); + 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' ); @@ -226,7 +233,7 @@ export function autop( text, br = true ) { text = text.replace( /

    ([^<]+)<\/(div|address|form)>/g, '

    $1

    ' ); // If an opening or closing block element tag is wrapped in a

    , unwrap it. - text = text.replace( new RegExp( '

    \s*(<\/?' + allBlocks + '[^>]*>)\s*<\/p>', 'g' ), '$1' ); + text = text.replace( new RegExp( '

    \\s*(<\/?' + allBlocks + '[^>]*>)\\s*<\/p>', 'g' ), '$1' ); // In some cases

  • may get wrapped in

    , fix them. text = text.replace( /

    (/g, '$1' ); @@ -236,10 +243,10 @@ export function autop( text, br = true ) { text = text.replace( /<\/blockquote><\/p>/g, '

    ' ); // If an opening or closing block element tag is preceded by an opening

    tag, remove it. - text = text.replace( new RegExp( '

    \s*(<\/?' + allBlocks + '[^>]*>)', 'g' ), '$1' ); + text = text.replace( new RegExp( '

    \\s*(<\/?' + allBlocks + '[^>]*>)', 'g' ), '$1' ); // If an opening or closing block element tag is followed by a closing

    tag, remove it. - text = text.replace( new RegExp( '(<\/?' + allBlocks + '[^>]*>)\s*<\/p>', 'g' ), '$1' ); + text = text.replace( new RegExp( '(<\/?' + allBlocks + '[^>]*>)\\s*<\/p>', 'g' ), '$1' ); // Optionally insert line breaks. if ( br ) { @@ -257,7 +264,7 @@ export function autop( text, br = true ) { } // If a
    tag is after an opening or closing block tag, remove it. - text = text.replace( new RegExp( '(<\/?' + allBlocks + '[^>]*>)\s*
    ', 'g' ), '$1' ); + text = text.replace( new RegExp( '(<\/?' + allBlocks + '[^>]*>)\\s*
    ', 'g' ), '$1' ); // If a
    tag is before a subset of opening or closing block tags, remove it. text = text.replace( /
    (\s*<\/?(?:p|li|div|dl|dd|dt|th|pre|td|ul|ol)[^>]*>)/g, '$1' ); @@ -278,12 +285,19 @@ export function autop( text, br = true ) { } /** - * Replaces

    tags with two line breaks. "Opposite" of autop(). + * Replaces `

    ` tags with two line breaks. "Opposite" of autop(). * - * Replaces

    tags with two line breaks except where the

    has attributes. - * Unifies whitespace. Indents

  • ,
    and
    for better readability. + * Replaces `

    ` tags with two line breaks except where the `

    ` has attributes. + * Unifies whitespace. Indents `

  • `, `
    ` and `
    ` for better readability. * * @param {string} html The content from the editor. + * + * @example + * ```js + * import { removep } from '@wordpress/autop'; + * removep( '

    my text

    ' ); // "my text" + * ``` + * * @return {string} The content with stripped paragraph tags. */ export function removep( html ) { diff --git a/packages/autop/src/test/index.test.js b/packages/autop/src/test/index.test.js index ea8e4c605343ef..9e945d3d9e8ced 100644 --- a/packages/autop/src/test/index.test.js +++ b/packages/autop/src/test/index.test.js @@ -495,3 +495,10 @@ test( 'that autop doses not add extra closing p in 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
    content
    '; + const expected = '

    Testing autop with a div

    \n
    content
    '; + + expect( autop( content ).trim() ).toBe( expected ); +} ); diff --git a/packages/babel-plugin-import-jsx-pragma/CHANGELOG.md b/packages/babel-plugin-import-jsx-pragma/CHANGELOG.md index 33b89b5ae57b01..69c48331c9b90a 100644 --- a/packages/babel-plugin-import-jsx-pragma/CHANGELOG.md +++ b/packages/babel-plugin-import-jsx-pragma/CHANGELOG.md @@ -1,3 +1,13 @@ +## 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 diff --git a/packages/babel-plugin-import-jsx-pragma/README.md b/packages/babel-plugin-import-jsx-pragma/README.md index fc32f27a90204e..21ad31fab22167 100644 --- a/packages/babel-plugin-import-jsx-pragma/README.md +++ b/packages/babel-plugin-import-jsx-pragma/README.md @@ -4,7 +4,7 @@ Babel transform plugin for automatically injecting an import to be used as the p [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. +**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 @@ -30,6 +30,8 @@ module.exports = { }; ``` +_Note:_ `@wordpress/babel-plugin-import-jsx-pragma` is now included in `@wordpress/babel-preset-default` (default preset for WordPress development). If you are using it, you shouldn't need to include this plugin anymore 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. diff --git a/packages/babel-plugin-import-jsx-pragma/index.js b/packages/babel-plugin-import-jsx-pragma/index.js new file mode 100644 index 00000000000000..7953640c484ab3 --- /dev/null +++ b/packages/babel-plugin-import-jsx-pragma/index.js @@ -0,0 +1,80 @@ +/** + * 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} 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', + 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: { + JSXElement( path, state ) { + if ( state.hasUndeclaredScopeVariable ) { + return; + } + + const { scopeVariable } = getOptions( state ); + state.hasUndeclaredScopeVariable = ! path.scope.hasBinding( scopeVariable ); + }, + Program: { + exit( path, state ) { + if ( ! state.hasUndeclaredScopeVariable ) { + return; + } + + const { scopeVariable, source, isDefault } = getOptions( state ); + + let specifier; + if ( isDefault ) { + specifier = t.importDefaultSpecifier( + t.identifier( scopeVariable ) + ); + } else { + specifier = t.importSpecifier( + t.identifier( scopeVariable ), + t.identifier( scopeVariable ) + ); + } + + const importDeclaration = t.importDeclaration( + [ specifier ], + t.stringLiteral( source ) + ); + + path.unshiftContainer( 'body', importDeclaration ); + }, + }, + }, + }; +}; diff --git a/packages/babel-plugin-import-jsx-pragma/package.json b/packages/babel-plugin-import-jsx-pragma/package.json index 5cd00bf163f86f..5b0126c4903bf1 100644 --- a/packages/babel-plugin-import-jsx-pragma/package.json +++ b/packages/babel-plugin-import-jsx-pragma/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/babel-plugin-import-jsx-pragma", - "version": "1.1.2", + "version": "2.0.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", @@ -14,24 +14,19 @@ "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" + "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": [ - "build", - "build-module" + "index.js" ], - "main": "build/index.js", - "module": "build-module/index.js", - "dependencies": { - "@babel/runtime": "^7.0.0" - }, - "devDependencies": { - "@babel/core": "^7.0.0", - "@babel/plugin-syntax-jsx": "^7.0.0" - }, + "main": "index.js", "peerDependencies": { "@babel/core": "^7.0.0" }, diff --git a/packages/babel-plugin-import-jsx-pragma/src/index.js b/packages/babel-plugin-import-jsx-pragma/src/index.js deleted file mode 100644 index 89963e67d27e80..00000000000000 --- a/packages/babel-plugin-import-jsx-pragma/src/index.js +++ /dev/null @@ -1,102 +0,0 @@ -/** - * 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} 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', - 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. - */ -export default function( babel ) { - const { types: t } = babel; - - function getOptions( state ) { - if ( ! state._options ) { - state._options = { - ...DEFAULT_OPTIONS, - ...state.opts, - }; - } - - return state._options; - } - - return { - visitor: { - JSXElement( path, state ) { - state.hasJSX = true; - }, - ImportDeclaration( path, state ) { - if ( state.hasImportedScopeVariable ) { - return; - } - - const { scopeVariable, isDefault } = getOptions( state ); - - // Test that at least one import specifier exists matching the - // scope variable name. The module source is not verified since - // we must avoid introducing a conflicting import name, even if - // the scope variable is referenced from a different source. - state.hasImportedScopeVariable = path.node.specifiers.some( ( specifier ) => { - switch ( specifier.type ) { - case 'ImportSpecifier': - return ( - ! isDefault && - specifier.imported.name === scopeVariable - ); - - case 'ImportDefaultSpecifier': - return isDefault; - } - } ); - }, - Program: { - exit( path, state ) { - if ( ! state.hasJSX || state.hasImportedScopeVariable ) { - return; - } - - const { scopeVariable, source, isDefault } = getOptions( state ); - - let specifier; - if ( isDefault ) { - specifier = t.importDefaultSpecifier( - t.identifier( scopeVariable ) - ); - } else { - specifier = t.importSpecifier( - t.identifier( scopeVariable ), - t.identifier( scopeVariable ) - ); - } - - const importDeclaration = t.importDeclaration( - [ specifier ], - t.stringLiteral( source ) - ); - - path.unshiftContainer( 'body', importDeclaration ); - }, - }, - }, - }; -} diff --git a/packages/babel-plugin-import-jsx-pragma/test/index.js b/packages/babel-plugin-import-jsx-pragma/test/index.js index 800b75e9727d65..a10207a5d55634 100644 --- a/packages/babel-plugin-import-jsx-pragma/test/index.js +++ b/packages/babel-plugin-import-jsx-pragma/test/index.js @@ -6,7 +6,7 @@ import { transformSync } from '@babel/core'; /** * Internal dependencies */ -import plugin from '../src'; +import plugin from '../'; describe( 'babel-plugin-import-jsx-pragma', () => { function getTransformedCode( source, options = {} ) { @@ -35,11 +35,18 @@ describe( 'babel-plugin-import-jsx-pragma', () => { expect( string ).toBe( original ); } ); + it( 'does nothing if the scope variable is already defined', () => { + const original = 'const React = require("react");\n\nlet foo = ;'; + const string = getTransformedCode( original ); + + expect( string ).toBe( original ); + } ); + it( 'adds import for scope variable', () => { const original = 'let foo = ;'; const string = getTransformedCode( original ); - expect( string ).toBe( 'import React from "react";\nlet foo = ;' ); + expect( string ).toBe( 'import React from "react";\n' + original ); } ); it( 'allows options customization', () => { @@ -50,6 +57,35 @@ describe( 'babel-plugin-import-jsx-pragma', () => { isDefault: false, } ); - expect( string ).toBe( 'import { createElement } from "@wordpress/element";\nlet foo = ;' ); + 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 = ;\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} = wp.element;\nlet foo = ;'; + const string = getTransformedCode( original, { + scopeVariable: 'createElement', + } ); + + expect( string ).toBe( original ); + } ); + + 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 = ;\n})();'; + const string = getTransformedCode( original, { + scopeVariable: 'createElement', + } ); + + expect( string ).toBe( original ); } ); } ); diff --git a/packages/babel-plugin-makepot/CHANGELOG.md b/packages/babel-plugin-makepot/CHANGELOG.md index c70831cbada60c..e6518a0d859e41 100644 --- a/packages/babel-plugin-makepot/CHANGELOG.md +++ b/packages/babel-plugin-makepot/CHANGELOG.md @@ -1,3 +1,10 @@ +## v2.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 diff --git a/packages/babel-plugin-makepot/README.md b/packages/babel-plugin-makepot/README.md index 06e8ceaf341d56..6cade321228b30 100644 --- a/packages/babel-plugin-makepot/README.md +++ b/packages/babel-plugin-makepot/README.md @@ -3,9 +3,11 @@ 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 -[ "@wordpress/babel-plugin-makepot", { - "output": "languages/myplugin.pot" -} ] +{ + "plugins": [ + [ "@wordpress/babel-plugin-makepot", { "output": "languages/myplugin.pot" } ], + ] +} ``` ## Installation @@ -15,3 +17,5 @@ Install the module: ```bash npm install @wordpress/babel-plugin-makepot --save-dev ``` + +

    Code is Poetry.

    diff --git a/packages/babel-plugin-makepot/package.json b/packages/babel-plugin-makepot/package.json index e21a023be7d3d3..973c4162a48dc5 100644 --- a/packages/babel-plugin-makepot/package.json +++ b/packages/babel-plugin-makepot/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/babel-plugin-makepot", - "version": "2.1.2", + "version": "3.0.0", "description": "WordPress Babel internationalization (i18n) plugin.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -13,11 +13,15 @@ "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/babel-plugin-makepot/README.md", "repository": { "type": "git", - "url": "https://github.com/WordPress/gutenberg.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" @@ -25,13 +29,9 @@ "main": "build/index.js", "module": "build-module/index.js", "dependencies": { - "@babel/runtime": "^7.0.0", + "@babel/runtime": "^7.3.1", "gettext-parser": "^1.3.1", - "lodash": "^4.17.10" - }, - "devDependencies": { - "@babel/core": "^7.0.0", - "@babel/traverse": "^7.0.0" + "lodash": "^4.17.11" }, "peerDependencies": { "@babel/core": "^7.0.0" diff --git a/packages/babel-plugin-makepot/src/index.js b/packages/babel-plugin-makepot/src/index.js index 1f9524f524ac57..ad0cde941aecb8 100644 --- a/packages/babel-plugin-makepot/src/index.js +++ b/packages/babel-plugin-makepot/src/index.js @@ -107,15 +107,15 @@ function getNodeAsString( node ) { } /** - * Returns translator comment for a given AST traversal path if one exists. + * 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} Translator comment. + * @return {?string} Extracted comment. */ -function getTranslatorComment( path, _originalNodeLine ) { +function getExtractedComment( path, _originalNodeLine ) { const { node, parent, parentPath } = path; // Assign original node line so we can keep track in recursion whether a @@ -152,7 +152,7 @@ function getTranslatorComment( path, _originalNodeLine ) { // 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 getTranslatorComment( parentPath, _originalNodeLine ); + return getExtractedComment( parentPath, _originalNodeLine ); } } @@ -246,7 +246,7 @@ module.exports = function() { // Attempt to exract nplurals from header const pluralsMatch = ( baseData.headers[ 'plural-forms' ] || '' ).match( /nplurals\s*=\s*(\d+);/ ); if ( pluralsMatch ) { - nplurals = pluralsMatch[ 1 ]; + nplurals = parseInt( pluralsMatch[ 1 ], 10 ); } } @@ -266,9 +266,9 @@ module.exports = function() { }; // If exists, also assign translator comment - const translator = getTranslatorComment( path ); + const translator = getExtractedComment( path ); if ( translator ) { - translation.comments.translator = translator; + translation.comments.extracted = translator; } // Create context grouping for translation if not yet exists @@ -340,6 +340,6 @@ module.exports = function() { }; module.exports.getNodeAsString = getNodeAsString; -module.exports.getTranslatorComment = getTranslatorComment; +module.exports.getExtractedComment = getExtractedComment; module.exports.isValidTranslationKey = isValidTranslationKey; module.exports.isSameTranslation = isSameTranslation; diff --git a/packages/babel-plugin-makepot/test/index.js b/packages/babel-plugin-makepot/test/index.js index 17af021380b9e3..52ae1dcc5b9134 100644 --- a/packages/babel-plugin-makepot/test/index.js +++ b/packages/babel-plugin-makepot/test/index.js @@ -12,7 +12,7 @@ import babelPlugin from '../src'; describe( 'babel-plugin', () => { const { getNodeAsString, - getTranslatorComment, + getExtractedComment, isValidTranslationKey, isSameTranslation, } = babelPlugin; @@ -43,12 +43,12 @@ describe( 'babel-plugin', () => { } ); } ); - describe( '.getTranslatorComment()', () => { + describe( '.getExtractedComment()', () => { function getCommentFromString( string ) { let comment; traverse( transformSync( string, { ast: true } ).ast, { CallExpression( path ) { - comment = getTranslatorComment( path ); + comment = getExtractedComment( path ); }, } ); diff --git a/packages/babel-preset-default/CHANGELOG.md b/packages/babel-preset-default/CHANGELOG.md index dc58eaec5c971c..ab2a808cdfee40 100644 --- a/packages/babel-preset-default/CHANGELOG.md +++ b/packages/babel-preset-default/CHANGELOG.md @@ -1,6 +1,17 @@ +## 4.0.0 (2019-03-06) + +### Breaking Change + +- 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 +### 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. diff --git a/packages/babel-preset-default/index.js b/packages/babel-preset-default/index.js index a2bfedebe1b370..5c7f22d98439a8 100644 --- a/packages/babel-preset-default/index.js +++ b/packages/babel-preset-default/index.js @@ -1,30 +1,72 @@ 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.useBuiltIns = 'usage'; + } 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: [ - ! isTestEnv && [ '@babel/preset-env', { - modules: false, - targets: { - browsers: [ 'extends @wordpress/browserslist-config' ], - }, - } ], - isTestEnv && [ '@babel/preset-env', { - useBuiltIns: 'usage', - } ], - ].filter( Boolean ), + presets: [ getPresetEnv() ], plugins: [ - '@babel/plugin-proposal-object-rest-spread', - [ '@babel/plugin-transform-react-jsx', { + require.resolve( '@babel/plugin-proposal-object-rest-spread' ), + [ + require.resolve( '@wordpress/babel-plugin-import-jsx-pragma' ), + { + scopeVariable: 'createElement', + source: '@wordpress/element', + isDefault: false, + }, + ], + [ require.resolve( '@babel/plugin-transform-react-jsx' ), { pragma: 'createElement', } ], - '@babel/plugin-proposal-async-generator-functions', - ! isTestEnv && [ '@babel/plugin-transform-runtime', { - corejs: false, // We polyfill so we don't need core-js. - helpers: true, - regenerator: false, // We polyfill so we don't need regenerator. - useESModules: false, - } ], + require.resolve( '@babel/plugin-proposal-async-generator-functions' ), + maybeGetPluginTransformRuntime(), ].filter( Boolean ), }; }; diff --git a/packages/babel-preset-default/package.json b/packages/babel-preset-default/package.json index 08149a01f9b6b2..e3096fa847d455 100644 --- a/packages/babel-preset-default/package.json +++ b/packages/babel-preset-default/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/babel-preset-default", - "version": "3.0.1", + "version": "4.0.0", "description": "Default Babel preset for WordPress development.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -13,7 +13,8 @@ "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/babel-preset-default/README.md", "repository": { "type": "git", - "url": "https://github.com/WordPress/gutenberg.git" + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/babel-preset-default" }, "bugs": { "url": "https://github.com/WordPress/gutenberg/issues" @@ -21,16 +22,20 @@ "engines": { "node": ">=8" }, + "files": [ + "index.js" + ], "main": "index.js", "dependencies": { - "@babel/plugin-proposal-async-generator-functions": "^7.0.0", - "@babel/plugin-proposal-object-rest-spread": "^7.0.0", - "@babel/plugin-transform-react-jsx": "^7.0.0", - "@babel/plugin-transform-runtime": "^7.0.0", - "@babel/preset-env": "^7.0.0", - "@babel/runtime": "^7.0.0", - "@wordpress/browserslist-config": "file:../browserslist-config", - "babel-core": "^7.0.0-bridge.0" + "@babel/core": "^7.2.2", + "@babel/plugin-proposal-async-generator-functions": "^7.2.0", + "@babel/plugin-proposal-object-rest-spread": "^7.3.2", + "@babel/plugin-transform-react-jsx": "^7.3.0", + "@babel/plugin-transform-runtime": "^7.2.0", + "@babel/preset-env": "^7.3.1", + "@babel/runtime": "^7.3.1", + "@wordpress/babel-plugin-import-jsx-pragma": "file:../babel-plugin-import-jsx-pragma", + "@wordpress/browserslist-config": "file:../browserslist-config" }, "peerDependencies": { "@babel/core": "^7.0.0" diff --git a/packages/babel-preset-default/test/__snapshots__/index.js.snap b/packages/babel-preset-default/test/__snapshots__/index.js.snap index 5cf000511103d0..ed8a87a05dbfe0 100644 --- a/packages/babel-preset-default/test/__snapshots__/index.js.snap +++ b/packages/babel-preset-default/test/__snapshots__/index.js.snap @@ -2,6 +2,7 @@ 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 () { @@ -12,8 +13,8 @@ describe('Babel preset default', function () { function _foo() { _foo = _wrapAsyncGenerator( /*#__PURE__*/ - regeneratorRuntime.mark(function _callee() { - return regeneratorRuntime.wrap(function _callee$(_context) { + _regeneratorRuntime.mark(function _callee() { + return _regeneratorRuntime.wrap(function _callee$(_context) { while (1) { switch (_context.prev = _context.next) { case 0: @@ -38,9 +39,9 @@ describe('Babel preset default', function () { /*#__PURE__*/ _asyncToGenerator( /*#__PURE__*/ - regeneratorRuntime.mark(function _callee2() { + _regeneratorRuntime.mark(function _callee2() { var generator; - return regeneratorRuntime.wrap(function _callee2$(_context2) { + return _regeneratorRuntime.wrap(function _callee2$(_context2) { while (1) { switch (_context2.prev = _context2.next) { case 0: diff --git a/packages/babel-preset-default/test/index.js b/packages/babel-preset-default/test/index.js index 7465ce2f73512d..0be3197b9541cd 100644 --- a/packages/babel-preset-default/test/index.js +++ b/packages/babel-preset-default/test/index.js @@ -3,7 +3,7 @@ */ import path from 'path'; import { readFileSync } from 'fs'; -import { transform } from 'babel-core'; +import { transform } from '@babel/core'; /** * Internal dependencies diff --git a/packages/blob/README.md b/packages/blob/README.md index d8f1b1b359aa4b..c0aed95f3b17b9 100644 --- a/packages/blob/README.md +++ b/packages/blob/README.md @@ -10,4 +10,65 @@ Install the module npm install @wordpress/blob --save ``` +## API + + + +### createBlobURL + +[src/index.js#L15-L21](src/index.js#L15-L21) + +Create a blob URL from a file. + +**Parameters** + +- **file** `File`: The file to create a blob URL for. + +**Returns** + +`string`: The blob URL. + +### getBlobByURL + +[src/index.js#L32-L34](src/index.js#L32-L34) + +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. + +### isBlobURL + +[src/index.js#L56-L61](src/index.js#L56-L61) + +Check whether a url is a blob url. + +**Parameters** + +- **url** `string`: The URL. + +**Returns** + +`boolean`: Is the url a blob url? + +### revokeBlobURL + +[src/index.js#L41-L47](src/index.js#L41-L47) + +Remove the resource and file cache from memory. + +**Parameters** + +- **url** `string`: The blob URL. + + + +

    Code is Poetry.

    diff --git a/packages/blob/package.json b/packages/blob/package.json index 01119602f55edd..a4032f11d80d52 100644 --- a/packages/blob/package.json +++ b/packages/blob/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/blob", - "version": "2.1.0", + "version": "2.2.0", "description": "Blob utilities for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -11,7 +11,8 @@ "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/blob/README.md", "repository": { "type": "git", - "url": "https://github.com/WordPress/gutenberg.git" + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/blob" }, "bugs": { "url": "https://github.com/WordPress/gutenberg/issues" @@ -20,7 +21,7 @@ "module": "build-module/index.js", "react-native": "src/index", "dependencies": { - "@babel/runtime": "^7.0.0" + "@babel/runtime": "^7.3.1" }, "publishConfig": { "access": "public" diff --git a/packages/blob/src/test/index.js b/packages/blob/src/test/index.js index 49adedc33e58ca..7604e3956b6d26 100644 --- a/packages/blob/src/test/index.js +++ b/packages/blob/src/test/index.js @@ -1,3 +1,6 @@ +/** + * Internal dependencies + */ import { isBlobURL, } from '../'; diff --git a/packages/block-editor/.npmrc b/packages/block-editor/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/block-editor/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md new file mode 100644 index 00000000000000..5c4cae61450ac4 --- /dev/null +++ b/packages/block-editor/CHANGELOG.md @@ -0,0 +1,5 @@ +## 1.0.0 (2019-03-06) + +### New Features + +- Initial version. diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md new file mode 100644 index 00000000000000..1d1089561ed2a8 --- /dev/null +++ b/packages/block-editor/README.md @@ -0,0 +1,486 @@ +# Block Editor + +Generic block editor module. + +## 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)._ + +## API + + + +### AlignmentToolbar + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### Autocomplete + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### BlockAlignmentToolbar + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### BlockControls + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### BlockEdit + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### BlockEditorKeyboardShortcuts + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### BlockEditorProvider + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### BlockFormatControls + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### BlockIcon + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### BlockInspector + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### BlockList + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### BlockMover + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### BlockNavigationDropdown + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### BlockSelectionClearer + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### BlockSettingsMenu + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### BlockTitle + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### BlockToolbar + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### ColorPalette + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### ContrastChecker + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### CopyHandler + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### createCustomColorsHOC + +[src/index.js#L15-L15](src/index.js#L15-L15) + +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. + +### DefaultBlockAppender + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### FontSizePicker + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### getColorClassName + +[src/index.js#L15-L15](src/index.js#L15-L15) + +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. + +### getColorObjectByAttributeValues + +[src/index.js#L15-L15](src/index.js#L15-L15) + +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** + +`?string`: 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. + +### getColorObjectByColorValue + +[src/index.js#L15-L15](src/index.js#L15-L15) + +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** + +`?string`: Returns the color object included in the colors array whose color property equals colorValue. Returns undefined if no color object matches this requirement. + +### getFontSize + +[src/index.js#L15-L15](src/index.js#L15-L15) + +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. + +### getFontSizeClass + +[src/index.js#L15-L15](src/index.js#L15-L15) + +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'. + +### InnerBlocks + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### Inserter + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### InspectorAdvancedControls + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### InspectorControls + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### MediaPlaceholder + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### MediaUpload + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### MediaUploadCheck + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### MultiBlocksSwitcher + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### MultiSelectScrollIntoView + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### NavigableToolbar + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### ObserveTyping + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### PanelColorSettings + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### PlainText + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### PreserveScrollInReorder + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### RichText + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### RichTextShortcut + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### RichTextToolbarButton + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### SETTINGS_DEFAULTS + +[src/index.js#L17-L17](src/index.js#L17-L17) + +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 + +### SkipToSelectedBlock + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### UnstableRichTextInputEvent + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### URLInput + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### URLInputButton + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### URLPopover + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### Warning + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### withColorContext + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### withColors + +[src/index.js#L15-L15](src/index.js#L15-L15) + +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. + +### withFontSizes + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Higher-order component, which handles font size logic for class generation, +font size value retrieval, and font size change handling. + +**Parameters** + +- **args** `...(object|string)`: The arguments should all be strings Each string contains the font size attribute name e.g: 'fontSize'. + +**Returns** + +`Function`: Higher-order component. + +### WritingFlow + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### \_BlockSettingsMenuFirstItem + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + +### \_BlockSettingsMenuPluginsExtension + +[src/index.js#L15-L15](src/index.js#L15-L15) + +Undocumented declaration. + + + + +

    Code is Poetry.

    diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json new file mode 100644 index 00000000000000..b6f19c50b0f150 --- /dev/null +++ b/packages/block-editor/package.json @@ -0,0 +1,56 @@ +{ + "name": "@wordpress/block-editor", + "version": "1.0.0", + "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.0.0", + "@wordpress/a11y": "file:../a11y", + "@wordpress/blob": "file:../blob", + "@wordpress/blocks": "file:../blocks", + "@wordpress/components": "file:../components", + "@wordpress/compose": "file:../compose", + "@wordpress/core-data": "file:../core-data", + "@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/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", + "dom-scroll-into-view": "^1.2.1", + "lodash": "^4.17.10", + "redux-multi": "^0.1.12", + "refx": "^3.0.0", + "rememo": "^3.0.0", + "tinycolor2": "^1.4.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/block-editor/src/components/alignment-toolbar/index.js b/packages/block-editor/src/components/alignment-toolbar/index.js new file mode 100644 index 00000000000000..1124f44f033e1f --- /dev/null +++ b/packages/block-editor/src/components/alignment-toolbar/index.js @@ -0,0 +1,80 @@ +/** + * External dependencies + */ +import { find } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Toolbar } from '@wordpress/components'; +import { withViewportMatch } from '@wordpress/viewport'; +import { withSelect } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { withBlockEditContext } from '../block-edit/context'; + +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( { isCollapsed, value, onChange, alignmentControls = DEFAULT_ALIGNMENT_CONTROLS } ) { + function applyOrUnset( align ) { + return () => onChange( value === align ? undefined : align ); + } + + const activeAlignment = find( alignmentControls, ( control ) => control.align === value ); + + return ( + { + const { align } = control; + const isActive = ( value === align ); + + return { + ...control, + isActive, + onClick: applyOrUnset( align ), + }; + } ) } + /> + ); +} + +export default compose( + withBlockEditContext( ( { clientId } ) => { + return { + clientId, + }; + } ), + withViewportMatch( { isLargeViewport: 'medium' } ), + withSelect( ( select, { clientId, isLargeViewport, isCollapsed } ) => { + const { getBlockRootClientId, getSettings } = select( 'core/block-editor' ); + return { + isCollapsed: isCollapsed || ! isLargeViewport || ( + ! getSettings().hasFixedToolbar && + getBlockRootClientId( clientId ) + ), + }; + } ), +)( AlignmentToolbar ); diff --git a/packages/editor/src/components/alignment-toolbar/test/__snapshots__/index.js.snap b/packages/block-editor/src/components/alignment-toolbar/test/__snapshots__/index.js.snap similarity index 100% rename from packages/editor/src/components/alignment-toolbar/test/__snapshots__/index.js.snap rename to packages/block-editor/src/components/alignment-toolbar/test/__snapshots__/index.js.snap diff --git a/packages/editor/src/components/alignment-toolbar/test/index.js b/packages/block-editor/src/components/alignment-toolbar/test/index.js similarity index 100% rename from packages/editor/src/components/alignment-toolbar/test/index.js rename to packages/block-editor/src/components/alignment-toolbar/test/index.js diff --git a/packages/editor/src/components/autocomplete/README.md b/packages/block-editor/src/components/autocomplete/README.md similarity index 100% rename from packages/editor/src/components/autocomplete/README.md rename to packages/block-editor/src/components/autocomplete/README.md diff --git a/packages/editor/src/components/autocomplete/index.js b/packages/block-editor/src/components/autocomplete/index.js similarity index 100% rename from packages/editor/src/components/autocomplete/index.js rename to packages/block-editor/src/components/autocomplete/index.js diff --git a/packages/editor/src/components/autocomplete/test/index.js b/packages/block-editor/src/components/autocomplete/test/index.js similarity index 100% rename from packages/editor/src/components/autocomplete/test/index.js rename to packages/block-editor/src/components/autocomplete/test/index.js diff --git a/packages/block-editor/src/components/block-actions/index.js b/packages/block-editor/src/components/block-actions/index.js new file mode 100644 index 00000000000000..3b3f8032432e8b --- /dev/null +++ b/packages/block-editor/src/components/block-actions/index.js @@ -0,0 +1,112 @@ +/** + * 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 } from '@wordpress/blocks'; + +function BlockActions( { + onDuplicate, + onRemove, + onInsertBefore, + onInsertAfter, + isLocked, + canDuplicate, + children, +} ) { + return children( { + onDuplicate, + onRemove, + onInsertAfter, + onInsertBefore, + isLocked, + canDuplicate, + } ); +} + +export default compose( [ + withSelect( ( select, props ) => { + const { + getBlocksByClientId, + getTemplateLock, + getBlockRootClientId, + } = select( 'core/block-editor' ); + + const blocks = getBlocksByClientId( props.clientIds ); + const canDuplicate = every( blocks, ( block ) => { + return !! block && hasBlockSupport( block.name, 'multiple', true ); + } ); + const rootClientId = getBlockRootClientId( props.clientIds[ 0 ] ); + + return { + isLocked: !! getTemplateLock( rootClientId ), + blocks, + canDuplicate, + rootClientId, + extraProps: props, + }; + } ), + withDispatch( ( dispatch, props, { select } ) => { + const { + clientIds, + rootClientId, + blocks, + isLocked, + canDuplicate, + } = props; + + const { + insertBlocks, + multiSelect, + removeBlocks, + insertDefaultBlock, + } = dispatch( 'core/block-editor' ); + + return { + onDuplicate() { + if ( isLocked || ! 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 ); + } + }, + }; + } ), +] )( BlockActions ); diff --git a/packages/block-editor/src/components/block-alignment-toolbar/index.js b/packages/block-editor/src/components/block-alignment-toolbar/index.js new file mode 100644 index 00000000000000..adf02cd90b08b2 --- /dev/null +++ b/packages/block-editor/src/components/block-alignment-toolbar/index.js @@ -0,0 +1,88 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Toolbar } from '@wordpress/components'; +import { withViewportMatch } from '@wordpress/viewport'; +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 WIDE_CONTROLS = [ 'wide', 'full' ]; + +export function BlockAlignmentToolbar( { isCollapsed, value, onChange, controls = DEFAULT_CONTROLS, wideControlsEnabled = false } ) { + function applyOrUnset( align ) { + return () => onChange( value === align ? undefined : align ); + } + + const enabledControls = wideControlsEnabled ? + controls : + controls.filter( ( control ) => WIDE_CONTROLS.indexOf( control ) === -1 ); + + const activeAlignment = BLOCK_ALIGNMENTS_CONTROLS[ value ]; + + return ( + { + return { + ...BLOCK_ALIGNMENTS_CONTROLS[ control ], + isActive: value === control, + onClick: applyOrUnset( control ), + }; + } ) + } + /> + ); +} + +export default compose( + withBlockEditContext( ( { clientId } ) => { + return { + clientId, + }; + } ), + withViewportMatch( { isLargeViewport: 'medium' } ), + withSelect( ( select, { clientId, isLargeViewport, isCollapsed } ) => { + const { getBlockRootClientId, getSettings } = select( 'core/block-editor' ); + const settings = getSettings(); + return { + wideControlsEnabled: settings.alignWide, + isCollapsed: isCollapsed || ! isLargeViewport || ( + ! settings.hasFixedToolbar && + getBlockRootClientId( clientId ) + ), + }; + } ), +)( BlockAlignmentToolbar ); diff --git a/packages/editor/src/components/block-alignment-toolbar/test/__snapshots__/index.js.snap b/packages/block-editor/src/components/block-alignment-toolbar/test/__snapshots__/index.js.snap similarity index 100% rename from packages/editor/src/components/block-alignment-toolbar/test/__snapshots__/index.js.snap rename to packages/block-editor/src/components/block-alignment-toolbar/test/__snapshots__/index.js.snap diff --git a/packages/editor/src/components/block-alignment-toolbar/test/index.js b/packages/block-editor/src/components/block-alignment-toolbar/test/index.js similarity index 100% rename from packages/editor/src/components/block-alignment-toolbar/test/index.js rename to packages/block-editor/src/components/block-alignment-toolbar/test/index.js diff --git a/packages/editor/src/components/block-compare/README.md b/packages/block-editor/src/components/block-compare/README.md similarity index 100% rename from packages/editor/src/components/block-compare/README.md rename to packages/block-editor/src/components/block-compare/README.md diff --git a/packages/block-editor/src/components/block-compare/block-view.js b/packages/block-editor/src/components/block-compare/block-view.js new file mode 100644 index 00000000000000..274c2896d98a9e --- /dev/null +++ b/packages/block-editor/src/components/block-compare/block-view.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ +import { Button } from '@wordpress/components'; + +const BlockView = ( { title, rawContent, renderedContent, action, actionText, className } ) => { + return ( +
    +
    +

    { title }

    + +
    + { rawContent } +
    + +
    + { renderedContent } +
    +
    + +
    + +
    +
    + ); +}; + +export default BlockView; diff --git a/packages/block-editor/src/components/block-compare/index.js b/packages/block-editor/src/components/block-compare/index.js new file mode 100644 index 00000000000000..1b294649e9c627 --- /dev/null +++ b/packages/block-editor/src/components/block-compare/index.js @@ -0,0 +1,85 @@ +/** + * 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, getSaveElement } 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 { item.value }; + } ); + } + + getOriginalContent( block ) { + return { + rawContent: block.originalContent, + renderedContent: getSaveElement( block.name, block.attributes ), + }; + } + + 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 ) ); + const renderedContent = newBlocks.map( ( item ) => getSaveElement( item.name, item.attributes, item.innerBlocks ) ); + + return { + rawContent: newContent.join( '' ), + renderedContent, + }; + } + + render() { + const { block, onKeep, onConvert, convertor, convertButtonText } = this.props; + const original = this.getOriginalContent( block ); + const converted = this.getConvertedContent( convertor( block ) ); + const difference = this.getDifference( original.rawContent, converted.rawContent ); + + return ( +
    + + + +
    + ); + } +} + +export default BlockCompare; diff --git a/packages/block-editor/src/components/block-compare/style.scss b/packages/block-editor/src/components/block-compare/style.scss new file mode 100644 index 00000000000000..f520ee566097bb --- /dev/null +++ b/packages/block-editor/src/components/block-compare/style.scss @@ -0,0 +1,79 @@ +/** + * 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; + } + + .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/packages/block-editor/src/components/block-compare/test/__snapshots__/block-view.js.snap b/packages/block-editor/src/components/block-compare/test/__snapshots__/block-view.js.snap new file mode 100644 index 00000000000000..f28df1bbb5548a --- /dev/null +++ b/packages/block-editor/src/components/block-compare/test/__snapshots__/block-view.js.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BlockView should match snapshot 1`] = ` +
    +
    +

    + title +

    +
    + raw +
    +
    + render +
    +
    +
    + + action + +
    +
    +`; diff --git a/packages/editor/src/components/block-compare/test/block-view.js b/packages/block-editor/src/components/block-compare/test/block-view.js similarity index 100% rename from packages/editor/src/components/block-compare/test/block-view.js rename to packages/block-editor/src/components/block-compare/test/block-view.js diff --git a/packages/editor/src/components/block-controls/index.js b/packages/block-editor/src/components/block-controls/index.js similarity index 100% rename from packages/editor/src/components/block-controls/index.js rename to packages/block-editor/src/components/block-controls/index.js diff --git a/packages/block-editor/src/components/block-controls/test/__snapshots__/index.js.snap b/packages/block-editor/src/components/block-controls/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000000..681c33a42d9976 --- /dev/null +++ b/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`] = ` + + + +

    + Child +

    +
    +
    +
    +`; diff --git a/packages/block-editor/src/components/block-controls/test/index.js b/packages/block-editor/src/components/block-controls/test/index.js new file mode 100644 index 00000000000000..f970dc030cadbe --- /dev/null +++ b/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( + + +

    Child

    +
    +
    + ); + + expect( wrapper ).toMatchSnapshot(); + } ); +} ); diff --git a/packages/block-editor/src/components/block-draggable/index.js b/packages/block-editor/src/components/block-draggable/index.js new file mode 100644 index 00000000000000..8818d58be9c2d2 --- /dev/null +++ b/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 ( + + { + ( { onDraggableStart, onDraggableEnd } ) => { + return children( { + onDraggableStart, + onDraggableEnd, + } ); + } + } + + ); +}; + +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/packages/editor/src/components/block-drop-zone/README.md b/packages/block-editor/src/components/block-drop-zone/README.md similarity index 100% rename from packages/editor/src/components/block-drop-zone/README.md rename to packages/block-editor/src/components/block-drop-zone/README.md diff --git a/packages/block-editor/src/components/block-drop-zone/index.js b/packages/block-editor/src/components/block-drop-zone/index.js new file mode 100644 index 00000000000000..fc71ee0a0c7d21 --- /dev/null +++ b/packages/block-editor/src/components/block-drop-zone/index.js @@ -0,0 +1,167 @@ +/** + * 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.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 { isLocked, index } = this.props; + if ( isLocked ) { + return null; + } + const isAppender = index === undefined; + + return ( + + + + ); + } +} + +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 { + isLocked: !! getTemplateLock( rootClientId ), + getClientIdsOfDescendants, + getBlockIndex, + }; + } ), + withFilters( 'editor.BlockDropZone' ) +)( BlockDropZone ); diff --git a/packages/block-editor/src/components/block-drop-zone/style.scss b/packages/block-editor/src/components/block-drop-zone/style.scss new file mode 100644 index 00000000000000..5eef5751111527 --- /dev/null +++ b/packages/block-editor/src/components/block-drop-zone/style.scss @@ -0,0 +1,23 @@ +// 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 { + background: none; + border-bottom: 3px solid theme(primary); + } + + &.is-close-to-top, + &.is-appender.is-close-to-top, + &.is-appender.is-close-to-bottom { + background: none; + border-top: 3px solid theme(primary); + border-bottom: none; + } +} diff --git a/packages/editor/src/components/block-edit/context.js b/packages/block-editor/src/components/block-edit/context.js similarity index 100% rename from packages/editor/src/components/block-edit/context.js rename to packages/block-editor/src/components/block-edit/context.js diff --git a/packages/editor/src/components/block-edit/edit.js b/packages/block-editor/src/components/block-edit/edit.js similarity index 100% rename from packages/editor/src/components/block-edit/edit.js rename to packages/block-editor/src/components/block-edit/edit.js diff --git a/packages/editor/src/components/block-edit/edit.native.js b/packages/block-editor/src/components/block-edit/edit.native.js similarity index 100% rename from packages/editor/src/components/block-edit/edit.native.js rename to packages/block-editor/src/components/block-edit/edit.native.js diff --git a/packages/editor/src/components/block-edit/index.js b/packages/block-editor/src/components/block-edit/index.js similarity index 100% rename from packages/editor/src/components/block-edit/index.js rename to packages/block-editor/src/components/block-edit/index.js diff --git a/packages/block-editor/src/components/block-edit/test/edit.js b/packages/block-editor/src/components/block-edit/test/edit.js new file mode 100644 index 00000000000000..d09ce608cc14bf --- /dev/null +++ b/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( ); + + expect( wrapper.type() ).toBe( null ); + } ); + + it( 'should use edit implementation of block', () => { + const edit = () =>
    ; + registerBlockType( 'core/test-block', { + save: noop, + category: 'common', + title: 'block title', + edit, + } ); + + const wrapper = shallow( ); + + expect( wrapper.exists( edit ) ).toBe( true ); + } ); + + it( 'should use save implementation of block as fallback', () => { + const save = () =>
    ; + registerBlockType( 'core/test-block', { + save, + category: 'common', + title: 'block title', + } ); + + const wrapper = shallow( ); + + expect( wrapper.exists( save ) ).toBe( true ); + } ); + + it( 'should combine the default class name with a custom one', () => { + const edit = ( { className } ) =>
    ; + const attributes = { + className: 'my-class', + }; + registerBlockType( 'core/test-block', { + edit, + save: noop, + category: 'common', + title: 'block title', + } ); + + const wrapper = shallow( + + ); + + expect( wrapper.find( edit ).hasClass( 'wp-block-test-block' ) ).toBe( true ); + expect( wrapper.find( edit ).hasClass( 'my-class' ) ).toBe( true ); + } ); +} ); diff --git a/packages/block-editor/src/components/block-editor-keyboard-shortcuts/index.js b/packages/block-editor/src/components/block-editor-keyboard-shortcuts/index.js new file mode 100644 index 00000000000000..4e97184cd93f97 --- /dev/null +++ b/packages/block-editor/src/components/block-editor-keyboard-shortcuts/index.js @@ -0,0 +1,158 @@ +/** + * External dependencies + */ +import { first, last, some, flow } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component, Fragment } 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 ( + + + { selectedBlockClientIds.length > 0 && ( + + { ( { onDuplicate, onRemove, onInsertAfter, onInsertBefore } ) => ( + + ) } + + ) } + + ); + } +} + +export default compose( [ + withSelect( ( select ) => { + const { + getBlockOrder, + getMultiSelectedBlockClientIds, + hasMultiSelection, + getBlockRootClientId, + getTemplateLock, + getSelectedBlockClientId, + } = select( 'core/block-editor' ); + const selectedBlockClientId = getSelectedBlockClientId(); + const selectedBlockClientIds = selectedBlockClientId ? [ selectedBlockClientId ] : getMultiSelectedBlockClientIds(); + + 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/packages/editor/src/components/block-format-controls/index.js b/packages/block-editor/src/components/block-format-controls/index.js similarity index 100% rename from packages/editor/src/components/block-format-controls/index.js rename to packages/block-editor/src/components/block-format-controls/index.js diff --git a/packages/block-editor/src/components/block-icon/index.js b/packages/block-editor/src/components/block-icon/index.js new file mode 100644 index 00000000000000..4d6106214df7d8 --- /dev/null +++ b/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: , + }; + } + + const renderedIcon = ; + const style = showColors ? { + backgroundColor: icon && icon.background, + color: icon && icon.foreground, + } : {}; + + return ( + + { renderedIcon } + + ); +} diff --git a/packages/block-editor/src/components/block-icon/style.scss b/packages/block-editor/src/components/block-icon/style.scss new file mode 100644 index 00000000000000..07eff392cca18e --- /dev/null +++ b/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/packages/editor/src/components/block-icon/test/index.js b/packages/block-editor/src/components/block-icon/test/index.js similarity index 100% rename from packages/editor/src/components/block-icon/test/index.js rename to packages/block-editor/src/components/block-icon/test/index.js diff --git a/packages/block-editor/src/components/block-inspector/index.js b/packages/block-editor/src/components/block-inspector/index.js new file mode 100644 index 00000000000000..fc54f9d174e61f --- /dev/null +++ b/packages/block-editor/src/components/block-inspector/index.js @@ -0,0 +1,96 @@ +/** + * 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'; +import { Fragment } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import SkipToSelectedBlock from '../skip-to-selected-block'; +import BlockIcon from '../block-icon'; +import InspectorControls from '../inspector-controls'; +import InspectorAdvancedControls from '../inspector-advanced-controls'; +import BlockStyles from '../block-styles'; +import MultiSelectionInspector from '../multi-selection-inspector'; + +const BlockInspector = ( { selectedBlockClientId, selectedBlockName, blockType, count, hasBlockStyles } ) => { + if ( count > 1 ) { + return ; + } + + 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 ) { + return { __( 'No block selected.' ) }; + } + + return ( + +
    + +
    +
    { blockType.title }
    +
    { blockType.description }
    +
    +
    + { hasBlockStyles && ( +
    + + + +
    + ) } +
    +
    + + { ( fills ) => ! isEmpty( fills ) && ( + + { fills } + + ) } + +
    + +
    + ); +}; + +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/packages/block-editor/src/components/block-inspector/style.scss b/packages/block-editor/src/components/block-inspector/style.scss new file mode 100644 index 00000000000000..2fdb6d04b44aab --- /dev/null +++ b/packages/block-editor/src/components/block-inspector/style.scss @@ -0,0 +1,45 @@ +.block-editor-block-inspector__no-blocks { + display: block; + font-size: $default-font-size; + background: $white; + padding: ($panel-padding * 2) $panel-padding; + text-align: center; +} + + +.block-editor-block-inspector__card { + display: flex; + align-items: flex-start; + margin: -16px; + padding: 16px; +} + +.block-editor-block-inspector__card-icon { + border: $border-width solid $light-gray-700; + padding: 7px; + margin-right: 10px; + height: 36px; + width: 36px; +} + +.block-editor-block-inspector__card-content { + flex-grow: 1; +} + +.block-editor-block-inspector__card-title { + font-weight: 500; + margin-bottom: 5px; +} + +.block-editor-block-inspector__card-description { + font-size: $default-font-size; +} + +.block-editor-block-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/packages/block-editor/src/components/block-list-appender/index.js b/packages/block-editor/src/components/block-list-appender/index.js new file mode 100644 index 00000000000000..5af9c48a66f044 --- /dev/null +++ b/packages/block-editor/src/components/block-list-appender/index.js @@ -0,0 +1,75 @@ +/** + * External dependencies + */ +import { last } from 'lodash'; + +/** + * WordPress dependencies + */ +import { withSelect } from '@wordpress/data'; +import { getDefaultBlockName } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; +import { IconButton } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import IgnoreNestedEvents from '../ignore-nested-events'; +import DefaultBlockAppender from '../default-block-appender'; +import Inserter from '../inserter'; + +function BlockListAppender( { + blockClientIds, + rootClientId, + canInsertDefaultBlock, + isLocked, +} ) { + if ( isLocked ) { + return null; + } + + if ( canInsertDefaultBlock ) { + return ( + + + + ); + } + + return ( +
    + ( + + ) } + isAppender + /> +
    + ); +} + +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/packages/block-editor/src/components/block-list-appender/style.scss b/packages/block-editor/src/components/block-list-appender/style.scss new file mode 100644 index 00000000000000..9f53f7572f1799 --- /dev/null +++ b/packages/block-editor/src/components/block-list-appender/style.scss @@ -0,0 +1,17 @@ +.block-list-appender > .block-editor-inserter { + display: block; +} + +.block-list-appender__toggle { + display: flex; + align-items: center; + justify-content: center; + padding: $grid-size-large; + outline: $border-width dashed $dark-gray-150; + width: 100%; + color: $dark-gray-500; + + &:hover { + outline: $border-width dashed $dark-gray-500; + } +} diff --git a/packages/editor/src/components/block-list/block-contextual-toolbar.js b/packages/block-editor/src/components/block-list/block-contextual-toolbar.js similarity index 85% rename from packages/editor/src/components/block-list/block-contextual-toolbar.js rename to packages/block-editor/src/components/block-list/block-contextual-toolbar.js index 8e6726e78f118e..36575d90f9ab59 100644 --- a/packages/editor/src/components/block-list/block-contextual-toolbar.js +++ b/packages/block-editor/src/components/block-list/block-contextual-toolbar.js @@ -13,7 +13,7 @@ function BlockContextualToolbar( { focusOnMount } ) { return ( diff --git a/packages/editor/src/components/block-list/block-crash-boundary.js b/packages/block-editor/src/components/block-list/block-crash-boundary.js similarity index 100% rename from packages/editor/src/components/block-list/block-crash-boundary.js rename to packages/block-editor/src/components/block-list/block-crash-boundary.js diff --git a/packages/editor/src/components/block-list/block-crash-warning.js b/packages/block-editor/src/components/block-list/block-crash-warning.js similarity index 100% rename from packages/editor/src/components/block-list/block-crash-warning.js rename to packages/block-editor/src/components/block-list/block-crash-warning.js diff --git a/packages/editor/src/components/block-list/block-html.js b/packages/block-editor/src/components/block-list/block-html.js similarity index 86% rename from packages/editor/src/components/block-list/block-html.js rename to packages/block-editor/src/components/block-list/block-html.js index ca2c057dc8a400..ce70122701828e 100644 --- a/packages/editor/src/components/block-list/block-html.js +++ b/packages/block-editor/src/components/block-list/block-html.js @@ -1,12 +1,12 @@ /** - * External Dependencies + * External dependencies */ import TextareaAutosize from 'react-autosize-textarea'; import { isEqual } from 'lodash'; /** - * WordPress Dependencies + * WordPress dependencies */ import { Component } from '@wordpress/element'; import { compose } from '@wordpress/compose'; @@ -56,7 +56,7 @@ export class BlockHTML extends Component { const { html } = this.state; return ( ( { - block: select( 'core/editor' ).getBlock( ownProps.clientId ), + block: select( 'core/block-editor' ).getBlock( ownProps.clientId ), } ) ), withDispatch( ( dispatch ) => ( { onChange( clientId, attributes, originalContent, isValid ) { - dispatch( 'core/editor' ).updateBlock( clientId, { attributes, originalContent, isValid } ); + dispatch( 'core/block-editor' ).updateBlock( clientId, { attributes, originalContent, isValid } ); }, } ) ), ] )( BlockHTML ); diff --git a/packages/editor/src/components/block-list/block-invalid-warning.js b/packages/block-editor/src/components/block-list/block-invalid-warning.js similarity index 82% rename from packages/editor/src/components/block-list/block-invalid-warning.js rename to packages/block-editor/src/components/block-list/block-invalid-warning.js index bc4555b1a3f0b0..6cd4bd8773d144 100644 --- a/packages/editor/src/components/block-list/block-invalid-warning.js +++ b/packages/block-editor/src/components/block-list/block-invalid-warning.js @@ -36,11 +36,12 @@ export class BlockInvalidWarning extends Component { } render() { - const { convertToHTML, convertToBlocks, convertToClassic, block } = this.props; + 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 }, ]; if ( compare ) { @@ -51,7 +52,7 @@ export class BlockInvalidWarning extends Component { __( 'Resolve Block' ) } onRequestClose={ this.onCompareClose } - className="editor-block-compare" + className="editor-block-compare block-editor-block-compare" > createBlock( 'core/html', { 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/editor' ).getBlock( clientId ), + block: select( 'core/block-editor' ).getBlock( clientId ), } ) ), withDispatch( ( dispatch, { block } ) => { - const { replaceBlock } = dispatch( 'core/editor' ); + const { replaceBlock } = dispatch( 'core/block-editor' ); return { convertToClassic() { @@ -114,6 +116,9 @@ export default compose( [ convertToBlocks() { replaceBlock( block.clientId, blockToBlocks( block ) ); }, + attemptBlockRecovery() { + replaceBlock( block.clientId, recoverBlock( block ) ); + }, }; } ), ] )( BlockInvalidWarning ); diff --git a/packages/editor/src/components/block-list/block-mobile-toolbar.js b/packages/block-editor/src/components/block-list/block-mobile-toolbar.js similarity index 80% rename from packages/editor/src/components/block-list/block-mobile-toolbar.js rename to packages/block-editor/src/components/block-list/block-mobile-toolbar.js index 53b17ebb2786c7..f35a7add73641a 100644 --- a/packages/editor/src/components/block-list/block-mobile-toolbar.js +++ b/packages/block-editor/src/components/block-list/block-mobile-toolbar.js @@ -11,7 +11,7 @@ import VisualEditorInserter from '../inserter'; function BlockMobileToolbar( { clientId } ) { return ( -
    +
    diff --git a/packages/editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js similarity index 84% rename from packages/editor/src/components/block-list/block.js rename to packages/block-editor/src/components/block-list/block.js index 2523029c010ed2..c5abcdc5deee36 100644 --- a/packages/editor/src/components/block-list/block.js +++ b/packages/block-editor/src/components/block-list/block.js @@ -26,7 +26,7 @@ import { KeyboardShortcuts, withFilters } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { withDispatch, withSelect } from '@wordpress/data'; import { withViewportMatch } from '@wordpress/viewport'; -import { compose } from '@wordpress/compose'; +import { compose, pure } from '@wordpress/compose'; /** * Internal dependencies @@ -59,8 +59,6 @@ export class BlockListBlock extends Component { this.maybeHover = this.maybeHover.bind( this ); this.forceFocusedContextualToolbar = this.forceFocusedContextualToolbar.bind( this ); this.hideHoverEffects = this.hideHoverEffects.bind( this ); - this.mergeBlocks = this.mergeBlocks.bind( this ); - this.insertBlocksAfter = this.insertBlocksAfter.bind( this ); this.onFocus = this.onFocus.bind( this ); this.preventDrag = this.preventDrag.bind( this ); this.onPointerDown = this.onPointerDown.bind( this ); @@ -101,8 +99,9 @@ export class BlockListBlock extends Component { this.focusTabbable( true ); } - // When triggering a multi-selection, - // move the focus to the wrapper of the first selected block. + // When triggering a multi-selection, move the focus to the wrapper of the first selected block. + // This ensures that it is not possible to continue editing the initially selected block + // when a multi-selection is triggered. if ( this.props.isFirstMultiSelected && ! prevProps.isFirstMultiSelected ) { this.wrapperNode.focus(); } @@ -215,7 +214,6 @@ export class BlockListBlock extends Component { isHovered || isPartOfMultiSelection || isSelected || - this.props.isMultiSelecting || this.hadTouchStart ) { return; @@ -235,34 +233,6 @@ export class BlockListBlock extends Component { } } - mergeBlocks( forward = false ) { - const { - clientId, - getPreviousBlockClientId, - getNextBlockClientId, - onMerge, - } = this.props; - const previousBlockClientId = getPreviousBlockClientId( clientId ); - const nextBlockClientId = getNextBlockClientId( clientId ); - // Do nothing when it's the first block. - if ( - ( ! forward && ! previousBlockClientId ) || - ( forward && ! nextBlockClientId ) - ) { - return; - } - - if ( forward ) { - onMerge( clientId, nextBlockClientId ); - } else { - onMerge( previousBlockClientId, clientId ); - } - } - - insertBlocksAfter( blocks ) { - this.props.onInsertBlocks( blocks, this.props.order + 1 ); - } - /** * 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 @@ -332,6 +302,8 @@ export class BlockListBlock extends Component { deleteOrInsertAfterWrapper( event ) { const { keyCode, target } = event; + // These block shortcuts should only trigger if the wrapper of the block is selected + // And when it's not a multi-selection to avoid conflicting with RichText/Inputs and multiselection. if ( ! this.props.isSelected || target !== this.wrapperNode || @@ -387,7 +359,6 @@ export class BlockListBlock extends Component { { ( { hoverArea } ) => { const { - order, mode, isFocusMode, hasFixedToolbar, @@ -401,7 +372,6 @@ export class BlockListBlock extends Component { isFirstMultiSelected, isTypingWithinBlock, isCaretWithinFormattedText, - isMultiSelecting, isEmptyDefaultBlock, isMovable, isParentOfSelectedBlock, @@ -411,7 +381,7 @@ export class BlockListBlock extends Component { isValid, attributes, } = this.props; - const isHovered = this.state.isHovered && ! isMultiSelecting; + const isHovered = this.state.isHovered && ! isPartOfMultiSelection; const blockType = getBlockType( name ); // translators: %s: Type of block (i.e. Text, Image etc) const blockLabel = sprintf( __( 'Block: %s' ), blockType.title ); @@ -424,11 +394,9 @@ export class BlockListBlock extends Component { // Empty paragraph blocks should always show up as unselected. const showEmptyBlockSideInserter = ( isSelected || isHovered ) && isEmptyDefaultBlock && isValid; - const showSideInserter = - ( isSelected || isHovered ) && isEmptyDefaultBlock; const shouldAppearSelected = ! isFocusMode && - ! showSideInserter && + ! showEmptyBlockSideInserter && isSelected && ! isTypingWithinBlock; const shouldAppearHovered = @@ -441,14 +409,13 @@ export class BlockListBlock extends Component { ! isFocusMode && ( isSelected || hoverArea === 'left' ) && ! showEmptyBlockSideInserter && - ! isMultiSelecting && ! isPartOfMultiSelection && ! isTypingWithinBlock; const shouldShowBreadcrumb = ! isFocusMode && isHovered && ! isEmptyDefaultBlock; const shouldShowContextualToolbar = ! hasFixedToolbar && - ! showSideInserter && + ! showEmptyBlockSideInserter && ( ( isSelected && ( ! isTypingWithinBlock || isCaretWithinFormattedText ) ) || isFirstMultiSelected ); @@ -464,7 +431,7 @@ export class BlockListBlock extends Component { // 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', + 'wp-block editor-block-list__block block-editor-block-list__block', { 'has-warning': ! isValid || !! error || isUnregisteredBlock, 'is-selected': shouldAppearSelected, @@ -502,9 +469,9 @@ export class BlockListBlock extends Component { isSelected={ isSelected } attributes={ attributes } setAttributes={ this.setAttributes } - insertBlocksAfter={ isLocked ? undefined : this.insertBlocksAfter } + insertBlocksAfter={ isLocked ? undefined : this.props.onInsertBlocksAfter } onReplace={ isLocked ? undefined : onReplace } - mergeBlocks={ isLocked ? undefined : this.mergeBlocks } + mergeBlocks={ isLocked ? undefined : this.props.onMerge } clientId={ clientId } isSelectionEnabled={ this.props.isSelectionEnabled } toggleSelection={ this.props.toggleSelection } @@ -549,29 +516,28 @@ export class BlockListBlock extends Component { /> ) } - { shouldRenderMovers && ( - - ) } { isFirstMultiSelected && ( ) } -
    +
    + { shouldRenderMovers && ( + + ) } { shouldShowBreadcrumb && ( { showEmptyBlockSideInserter && ( -
    +
    -
    +
    @@ -657,68 +625,64 @@ const applyWithSelect = withSelect( ( select, { clientId, rootClientId, isLargeViewport } ) => { const { isBlockSelected, - getBlockName, - isBlockValid, - getBlockAttributes, isAncestorMultiSelected, isBlockMultiSelected, isFirstMultiSelectedBlock, - isMultiSelecting, isTyping, isCaretWithinFormattedText, - getBlockIndex, getBlockMode, isSelectionEnabled, getSelectedBlocksInitialCaretPosition, - getEditorSettings, + getSettings, hasSelectedInnerBlock, getTemplateLock, - getPreviousBlockClientId, - getNextBlockClientId, - } = select( 'core/editor' ); + __unstableGetBlockWithoutInnerBlocks, + } = select( 'core/block-editor' ); + const block = __unstableGetBlockWithoutInnerBlocks( clientId ); const isSelected = isBlockSelected( clientId ); - const { hasFixedToolbar, focusMode } = getEditorSettings(); + const { hasFixedToolbar, focusMode } = getSettings(); const templateLock = getTemplateLock( rootClientId ); const isParentOfSelectedBlock = hasSelectedInnerBlock( clientId, true ); - const name = getBlockName( clientId ); - const attributes = getBlockAttributes( clientId ); + + // 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 ), - isMultiSelecting: isMultiSelecting(), // 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(), - order: getBlockIndex( clientId, rootClientId ), mode: getBlockMode( clientId ), isSelectionEnabled: isSelectionEnabled(), - initialPosition: getSelectedBlocksInitialCaretPosition(), + initialPosition: isSelected ? getSelectedBlocksInitialCaretPosition() : null, isEmptyDefaultBlock: name && isUnmodifiedDefaultBlock( { name, attributes } ), - isValid: isBlockValid( clientId ), isMovable: 'all' !== templateLock, isLocked: !! templateLock, isFocusMode: focusMode && isLargeViewport, hasFixedToolbar: hasFixedToolbar && isLargeViewport, + + // 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, - - // We only care about these selectors when events are triggered. - // We call them dynamically in the event handlers to avoid unnecessary re-renders. - getPreviousBlockClientId, - getNextBlockClientId, }; } ); const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => { - const { getBlockSelectionStart } = select( 'core/editor' ); const { updateBlockAttributes, selectBlock, @@ -728,9 +692,8 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => { removeBlock, mergeBlocks, replaceBlocks, - editPost, toggleSelection, - } = dispatch( 'core/editor' ); + } = dispatch( 'core/block-editor' ); return { onChange( clientId, attributes ) { @@ -744,26 +707,60 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => { insertBlocks( blocks, index, rootClientId ); }, onInsertDefaultBlockAfter() { - const { order, rootClientId } = ownProps; - insertDefaultBlock( {}, rootClientId, order + 1 ); + 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( ...args ) { - mergeBlocks( ...args ); + 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 ) { replaceBlocks( [ ownProps.clientId ], blocks ); }, - onMetaChange( meta ) { - editPost( { meta } ); + onMetaChange( updatedMeta ) { + const { getSettings } = select( 'core/block-editor' ); + const onChangeMeta = getSettings().__experimentalMetaSource.onChange; + onChangeMeta( updatedMeta ); }, onShiftSelection() { if ( ! ownProps.isSelectionEnabled ) { return; } + const { + getBlockSelectionStart, + } = select( 'core/block-editor' ); + if ( getBlockSelectionStart() ) { multiSelect( getBlockSelectionStart(), ownProps.clientId ); } else { @@ -777,9 +774,9 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => { } ); export default compose( - withFilters( 'editor.BlockListBlock' ), + pure, withViewportMatch( { isLargeViewport: 'medium' } ), applyWithSelect, applyWithDispatch, - withFilters( 'editor.__experimentalBlockListBlock' ) + withFilters( 'editor.BlockListBlock' ) )( BlockListBlock ); diff --git a/packages/editor/src/components/block-list/breadcrumb.js b/packages/block-editor/src/components/block-list/breadcrumb.js similarity index 86% rename from packages/editor/src/components/block-list/breadcrumb.js rename to packages/block-editor/src/components/block-list/breadcrumb.js index ca14b1d346c947..0f22df00312b35 100644 --- a/packages/editor/src/components/block-list/breadcrumb.js +++ b/packages/block-editor/src/components/block-list/breadcrumb.js @@ -51,12 +51,12 @@ export class BlockBreadcrumb extends Component { const { clientId, rootClientId } = this.props; return ( -
    +
    { rootClientId && ( - + ) } @@ -68,7 +68,7 @@ export class BlockBreadcrumb extends Component { export default compose( [ withSelect( ( select, ownProps ) => { - const { getBlockRootClientId } = select( 'core/editor' ); + const { getBlockRootClientId } = select( 'core/block-editor' ); const { clientId } = ownProps; return { diff --git a/packages/editor/src/components/block-list/hover-area.js b/packages/block-editor/src/components/block-list/hover-area.js similarity index 96% rename from packages/editor/src/components/block-list/hover-area.js rename to packages/block-editor/src/components/block-list/hover-area.js index ff0a16144b4f0f..a79b0bcd9b088b 100644 --- a/packages/editor/src/components/block-list/hover-area.js +++ b/packages/block-editor/src/components/block-list/hover-area.js @@ -76,7 +76,7 @@ class HoverArea extends Component { export default withSelect( ( select ) => { return { - isRTL: select( 'core/editor' ).getEditorSettings().isRTL, + isRTL: select( 'core/block-editor' ).getSettings().isRTL, }; } )( HoverArea ); diff --git a/packages/block-editor/src/components/block-list/index.js b/packages/block-editor/src/components/block-list/index.js new file mode 100644 index 00000000000000..5231dcb71749b7 --- /dev/null +++ b/packages/block-editor/src/components/block-list/index.js @@ -0,0 +1,273 @@ +/** + * External dependencies + */ +import { + findLast, + map, + invert, + mapValues, + sortBy, + throttle, +} from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { + withSelect, + withDispatch, + __experimentalAsyncModeProvider as AsyncModeProvider, +} from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import BlockListBlock from './block'; +import BlockListAppender from '../block-list-appender'; +import { getBlockDOMNode } from '../../utils/dom'; + +const forceSyncUpdates = ( WrappedComponent ) => ( props ) => { + return ( + + + + ); +}; +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. + * + * @return {void} + */ + 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. + * + * @return {void} + */ + 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. + * + * @return {void} + */ + 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 { + blockClientIds, + rootClientId, + isDraggable, + selectedBlockClientId, + multiSelectedBlockClientIds, + hasMultiSelection, + } = this.props; + + return ( +
    + { map( blockClientIds, ( clientId, blockIndex ) => { + const isBlockInSelection = hasMultiSelection ? + multiSelectedBlockClientIds.includes( clientId ) : + selectedBlockClientId === clientId; + + return ( + + + + ); + } ) } + +
    + ); + } +} + +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, + } = 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(), + }; + } ), + withDispatch( ( dispatch ) => { + const { + startMultiSelect, + stopMultiSelect, + multiSelect, + } = dispatch( 'core/block-editor' ); + + return { + onStartMultiSelect: startMultiSelect, + onStopMultiSelect: stopMultiSelect, + onMultiSelect: multiSelect, + }; + } ), +] )( BlockList ); diff --git a/packages/editor/src/components/block-list/insertion-point.js b/packages/block-editor/src/components/block-list/insertion-point.js similarity index 83% rename from packages/editor/src/components/block-list/insertion-point.js rename to packages/block-editor/src/components/block-list/insertion-point.js index 252925cc3a6c09..daa1a0f408f4e9 100644 --- a/packages/editor/src/components/block-list/insertion-point.js +++ b/packages/block-editor/src/components/block-list/insertion-point.js @@ -47,13 +47,13 @@ class BlockInsertionPoint extends Component { const { showInsertionPoint, rootClientId, - insertIndex, + clientId, } = this.props; return ( -
    +
    { showInsertionPoint && ( -
    +
    ) }
    @@ -85,15 +85,14 @@ export default withSelect( ( select, { clientId, rootClientId } ) => { getBlockIndex, getBlockInsertionPoint, isBlockInsertionPointVisible, - } = select( 'core/editor' ); + } = select( 'core/block-editor' ); const blockIndex = getBlockIndex( clientId, rootClientId ); - const insertIndex = blockIndex; const insertionPoint = getBlockInsertionPoint(); const showInsertionPoint = ( isBlockInsertionPointVisible() && - insertionPoint.index === insertIndex && + insertionPoint.index === blockIndex && insertionPoint.rootClientId === rootClientId ); - return { showInsertionPoint, insertIndex }; + return { showInsertionPoint }; } )( BlockInsertionPoint ); diff --git a/packages/editor/src/components/block-list/multi-controls.js b/packages/block-editor/src/components/block-list/multi-controls.js similarity index 96% rename from packages/editor/src/components/block-list/multi-controls.js rename to packages/block-editor/src/components/block-list/multi-controls.js index d00afc5a6af6d7..5fb34625389ee5 100644 --- a/packages/editor/src/components/block-list/multi-controls.js +++ b/packages/block-editor/src/components/block-list/multi-controls.js @@ -41,7 +41,7 @@ export default withSelect( ( select, { clientId } ) => { isMultiSelecting, getBlockIndex, getBlockCount, - } = select( 'core/editor' ); + } = select( 'core/block-editor' ); const clientIds = getMultiSelectedBlockClientIds(); const firstIndex = getBlockIndex( first( clientIds ), clientId ); const lastIndex = getBlockIndex( last( clientIds ), clientId ); diff --git a/packages/block-editor/src/components/block-list/style.scss b/packages/block-editor/src/components/block-list/style.scss new file mode 100644 index 00000000000000..4a72cb3ed3423d --- /dev/null +++ b/packages/block-editor/src/components/block-list/style.scss @@ -0,0 +1,1082 @@ +.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; + } + + // Space every block, and the default appender, using margins. + // This allows margins to collapse, which gives a better representation of how it looks on the frontend. + .block-editor-default-block-appender > .block-editor-default-block-appender__content, + > .block-editor-block-list__block > .block-editor-block-list__block-edit, + > .block-editor-block-list__layout > .block-editor-block-list__block > .block-editor-block-list__block-edit { + margin-top: $block-padding * 2 + $block-spacing; + margin-bottom: $block-padding * 2 + $block-spacing; + } +} + +.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; + transition: border-color 0.1s linear, box-shadow 0.1s linear; + pointer-events: none; + + // 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; + } + } + } + } + + // Hover style. + &.is-hovered > .block-editor-block-list__block-edit::before { + box-shadow: -$block-left-border-width 0 0 0 $light-gray-500; + + .is-dark-theme & { + box-shadow: -$block-left-border-width 0 0 0 $dark-gray-600; + } + } + + // Spotlight mode. + &.is-focus-mode:not(.is-multi-selected) { + opacity: 0.5; + transition: opacity 0.1s linear; + + &:not(.is-focused) .block-editor-block-list__block, + &.is-focused { + opacity: 1; + } + } +} + + +/** + * 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__empty-block-inserter, + &.is-typing .block-editor-block-list__side-inserter { + opacity: 0; + animation: none; + } + + .block-editor-block-list__empty-block-inserter, + .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; + } + } + + // 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; + } + } + + // 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 + > .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; + bottom: auto; + min-height: 0; + height: auto; + width: auto; + z-index: inherit; + + &::before { + content: none; + } + } + + > .block-editor-block-mover .block-editor-block-mover__control { + float: left; + } + + // Position hover label on the right + > .block-editor-block-list__breadcrumb { + right: -$border-width; + } + + // Hide mover until wide breakpoints, or it might be covered by toolbar + > .block-editor-block-mover { + display: none; + } + + @include break-wide() { + > .block-editor-block-mover { + display: block; + } + } + + // 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; + } + } + } + + // Wide + &[data-align="wide"] { + // Position mover + > .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 + > .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; + } + } +} + + +/** + * 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; + + // Stretch to fill half of the available space to increase hoverable area. + height: 100%; + max-height: $block-side-ui-width * 4; + } + + // 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; + + // 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; + } + + // For floats, show block mover when block is selected, and never on hover. + &[data-align="left"], + &[data-align="right"] { + // Show always when the block is selected. + &.is-selected > .block-editor-block-list__block-edit > .block-editor-block-mover { + // Don't show on mobile, allow the special mobile toolbar to work there. + display: none; + @include break-small() { + display: block; + opacity: 1; + animation: none; + + // Make wider and taller to make "safe" hover area bigger. + // The intent is to make it less likely that you hover float-adjacent + // blocks that visually appear below the block. + width: $block-side-ui-width + $block-side-ui-clearance + $block-padding + $border-width; + height: auto; + padding-bottom: $block-padding; + + // Unset the negative top margin, or it might overlap the block toolbar. + margin-top: 0; + } + } + + // Don't show on hover, or on the "ghost" when dragging. + &.is-hovered > .block-editor-block-list__block-edit > .block-editor-block-mover, + &.is-dragging > .block-editor-block-list__block-edit > .block-editor-block-mover { + display: none; + } + } +} + + +/** + * 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; + + // Show a clickable plus. + .block-editor-inserter__toggle { + margin-top: -4px; + border-radius: 50%; + color: $blue-medium-focus; + background: $white; + height: $block-padding * 2 + 8px; + width: $block-padding * 2 + 8px; + + &:not(:disabled):not([aria-disabled="true"]):hover { + box-shadow: none; + } + } + + // Hide both the button until hovered. + opacity: 0; + transition: opacity 0.1s linear; + + &: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; + + &: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; + } +} + +.block-editor-block-list__block.is-focus-mode:not(.is-multi-selected) > .block-editor-block-contextual-toolbar { + margin-left: -$block-side-ui-width; +} + +// 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; + } + + .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 { + padding: 0; + border: none; + line-height: 1; + font-family: $default-font; + font-size: 11px; + padding: 4px 4px; + background: $light-gray-500; + color: $dark-gray-900; + + .is-dark-theme & { + background: $dark-gray-600; + color: $white; + } + + // Animate in + .block-editor-block-list__block:hover & { + opacity: 0; + @include edit-post__fade-in-animation(60ms, 0.5s); + } + } + + // Position this above the toolbar of parent blocks. + .editor-inner-blocks & { + z-index: z-index(".editor-inner-blocks .block-editor-block-list__breadcrumb"); + } + + // 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; + } +} + +.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); + + // Bigger padding on mobile where blocks are edge to edge. + padding: 10px $block-padding; + @include break-small() { + padding: 10px $block-padding; + } +} diff --git a/packages/editor/src/components/block-list/test/block-html.js b/packages/block-editor/src/components/block-list/test/block-html.js similarity index 100% rename from packages/editor/src/components/block-list/test/block-html.js rename to packages/block-editor/src/components/block-list/test/block-html.js diff --git a/packages/editor/src/components/block-mover/drag-handle.js b/packages/block-editor/src/components/block-mover/drag-handle.js similarity index 88% rename from packages/editor/src/components/block-mover/drag-handle.js rename to packages/block-editor/src/components/block-mover/drag-handle.js index 560a0f50bc0788..86551bf4be0e30 100644 --- a/packages/editor/src/components/block-mover/drag-handle.js +++ b/packages/block-editor/src/components/block-mover/drag-handle.js @@ -4,7 +4,7 @@ import classnames from 'classnames'; /** - * WordPress dependencies + * Internal dependencies */ import BlockDraggable from '../block-draggable'; @@ -13,7 +13,7 @@ export const IconDragHandle = ( { isVisible, className, icon, onDragStart, onDra return null; } - const dragHandleClassNames = classnames( 'editor-block-mover__control-drag-handle', className ); + const dragHandleClassNames = classnames( 'editor-block-mover__control-drag-handle block-editor-block-mover__control-drag-handle', className ); return ( + + + + + { + getBlockMoverDescription( + blocksCount, + blockType && blockType.title, + firstIndex, + isFirst, + isLast, + -1, + ) + } + + + { + getBlockMoverDescription( + blocksCount, + blockType && blockType.title, + firstIndex, + isFirst, + isLast, + 1, + ) + } + +
    + ); + } +} + +export default compose( + withSelect( ( select, { clientIds } ) => { + const { getBlock, getBlockIndex, getTemplateLock, getBlockRootClientId } = select( 'core/block-editor' ); + const firstClientId = first( castArray( clientIds ) ); + const block = getBlock( firstClientId ); + const rootClientId = getBlockRootClientId( first( castArray( clientIds ) ) ); + + return { + firstIndex: getBlockIndex( firstClientId, rootClientId ), + blockType: block ? getBlockType( block.name ) : null, + isLocked: getTemplateLock( rootClientId ) === 'all', + rootClientId, + }; + } ), + 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/packages/editor/src/components/block-mover/mover-description.js b/packages/block-editor/src/components/block-mover/mover-description.js similarity index 100% rename from packages/editor/src/components/block-mover/mover-description.js rename to packages/block-editor/src/components/block-mover/mover-description.js diff --git a/packages/block-editor/src/components/block-mover/style.scss b/packages/block-editor/src/components/block-mover/style.scss new file mode 100644 index 00000000000000..5bcc3bfaa63a22 --- /dev/null +++ b/packages/block-editor/src/components/block-mover/style.scss @@ -0,0 +1,120 @@ +.block-editor-block-mover { + min-height: $empty-paragraph-height; + opacity: 0; + + &.is-visible { + @include edit-post__fade-in-animation; + } + + // 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. + @include break-small() { + .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; + + // 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. + } + + // Use opacity to work in various editor styles + color: $dark-opacity-300; + + .is-dark-theme & { + color: $light-opacity-300; + } + + // Nested movers have a background, so don't invert the colors there. + .is-dark-theme .wp-block .wp-block &, + .wp-block .is-dark-theme .wp-block & { + color: $dark-opacity-300; + } + + &[aria-disabled="true"] { + cursor: default; + pointer-events: none; + color: $dark-opacity-light-300; // Use opacity to work in various editor styles. + + .is-dark-theme & { + color: $light-opacity-light-300; + } + } +} + +.block-editor-block-mover__control-drag-handle { + cursor: move; // Fallback for IE/Edge < 14 + cursor: grab; + fill: currentColor; + border-radius: $radius-round-rectangle; + + &, + &: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; + + // Use opacity to work in various editor styles. + color: $dark-opacity-500; + + .is-dark-theme & { + color: $light-opacity-500; + } + + // Nested movers have a background, so don't invert the colors there. + .is-dark-theme .wp-block .wp-block &, + .wp-block .is-dark-theme .wp-block & { + color: $dark-opacity-500; + } + } + + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):active { + cursor: grabbing; + } +} + +.block-editor-block-mover__description { + display: none; +} + +// Apply a background in nested contexts, only on desktop. +.block-editor-block-mover__control-drag-handle:not(:disabled):not([aria-disabled="true"]):not(.is-default), +.block-editor-block-mover__control { + @include break-small() { + .block-editor-block-list__layout [data-align="right"] &, + .block-editor-block-list__layout [data-align="left"] &, + .block-editor-block-list__layout .block-editor-block-list__layout & { + background: $white; + box-shadow: inset 0 0 0 1px $light-gray-500; + + &:nth-child(-n+2) { + margin-bottom: -1px; + } + + &:hover, + &:active, + &:focus { + // Buttons are stacked with overlapping border to look like a unit, so elevate on interactions. + z-index: z-index(".block-editor-block-mover__control"); + } + } + } +} diff --git a/packages/block-editor/src/components/block-mover/test/index.js b/packages/block-editor/src/components/block-mover/test/index.js new file mode 100644 index 00000000000000..48809ae7905669 --- /dev/null +++ b/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( ); + expect( wrapper.type() ).toBe( null ); + } ); + + it( 'should render three icons with the following props', () => { + const blockMover = shallow( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + const moveDown = blockMover.childAt( 2 ); + expect( moveDown.props() ).toMatchObject( { + onClick: null, + 'aria-disabled': true, + } ); + } ); + } ); +} ); diff --git a/packages/editor/src/components/block-mover/test/mover-description.js b/packages/block-editor/src/components/block-mover/test/mover-description.js similarity index 100% rename from packages/editor/src/components/block-mover/test/mover-description.js rename to packages/block-editor/src/components/block-mover/test/mover-description.js diff --git a/packages/editor/src/components/block-navigation/dropdown.js b/packages/block-editor/src/components/block-navigation/dropdown.js similarity index 75% rename from packages/editor/src/components/block-navigation/dropdown.js rename to packages/block-editor/src/components/block-navigation/dropdown.js index c6b8e976db5cc3..ab8790cefa0d97 100644 --- a/packages/editor/src/components/block-navigation/dropdown.js +++ b/packages/block-editor/src/components/block-navigation/dropdown.js @@ -18,25 +18,28 @@ const MenuIcon = ( ); -function BlockNavigationDropdown( { hasBlocks } ) { +function BlockNavigationDropdown( { hasBlocks, isDisabled } ) { + const isEnabled = hasBlocks && ! isDisabled; + return ( ( - + } ) } @@ -49,6 +52,6 @@ function BlockNavigationDropdown( { hasBlocks } ) { export default withSelect( ( select ) => { return { - hasBlocks: !! select( 'core/editor' ).getBlockCount(), + hasBlocks: !! select( 'core/block-editor' ).getBlockCount(), }; } )( BlockNavigationDropdown ); diff --git a/packages/block-editor/src/components/block-navigation/index.js b/packages/block-editor/src/components/block-navigation/index.js new file mode 100644 index 00000000000000..b0542caa3d6f39 --- /dev/null +++ b/packages/block-editor/src/components/block-navigation/index.js @@ -0,0 +1,128 @@ +/** + * External dependencies + */ +import { map, noop } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { withSelect, withDispatch } from '@wordpress/data'; +import { Button, NavigableMenu } from '@wordpress/components'; +import { getBlockType } from '@wordpress/blocks'; +import { compose } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import BlockIcon from '../block-icon'; + +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 */ +
      + { map( blocks, ( block ) => { + const blockType = getBlockType( block.name ); + const isSelected = block.clientId === selectedBlockClientId; + + return ( +
    • +
      + +
      + { showNestedBlocks && !! block.innerBlocks && !! block.innerBlocks.length && ( + + ) } +
    • + ); + } ) } +
    + /* eslint-enable jsx-a11y/no-redundant-roles */ + ); +} + +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 ( + +

    { __( 'Block Navigation' ) }

    + { hasHierarchy && ( + + ) } + { ! hasHierarchy && ( + + ) } +
    + ); +} + +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/packages/block-editor/src/components/block-navigation/style.scss b/packages/block-editor/src/components/block-navigation/style.scss new file mode 100644 index 00000000000000..881a531ab71ccb --- /dev/null +++ b/packages/block-editor/src/components/block-navigation/style.scss @@ -0,0 +1,87 @@ +$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; + } +} diff --git a/packages/block-editor/src/components/block-preview/index.js b/packages/block-editor/src/components/block-preview/index.js new file mode 100644 index 00000000000000..97e9a9ebef40c3 --- /dev/null +++ b/packages/block-editor/src/components/block-preview/index.js @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { createBlock } from '@wordpress/blocks'; +import { Disabled } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import BlockEdit from '../block-edit'; + +/** + * Block Preview Component: It renders a preview given a block name and attributes. + * + * @param {Object} props Component props. + * + * @return {WPElement} Rendered element. + */ +function BlockPreview( props ) { + return ( +
    +
    { __( 'Preview' ) }
    + +
    + ); +} + +export function BlockPreviewContent( { name, attributes } ) { + const block = createBlock( name, attributes ); + return ( + + + + ); +} + +export default BlockPreview; diff --git a/packages/block-editor/src/components/block-preview/style.scss b/packages/block-editor/src/components/block-preview/style.scss new file mode 100644 index 00000000000000..ca518cf8b50917 --- /dev/null +++ b/packages/block-editor/src/components/block-preview/style.scss @@ -0,0 +1,35 @@ +.block-editor-block-preview { + pointer-events: none; + padding: 10px; + overflow: hidden; + display: none; + + @include break-medium { + display: block; + } + + .block-editor-block-preview__content { + padding: $block-padding; + border: $border-width solid $light-gray-500; + font-family: $editor-font; + + > div { + transform: scale(0.9); + transform-origin: center top; + font-family: $editor-font; + } + + > div section { + height: auto; + } + + > .reusable-block-indicator { + display: none; + } + } +} + +.block-editor-block-preview__title { + margin-bottom: 10px; + color: $dark-gray-300; +} diff --git a/packages/block-editor/src/components/block-selection-clearer/index.js b/packages/block-editor/src/components/block-selection-clearer/index.js new file mode 100644 index 00000000000000..be39e5df9e7de5 --- /dev/null +++ b/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 ( +
    + ); + } +} + +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/packages/editor/src/components/block-settings-menu/block-convert-button.js b/packages/block-editor/src/components/block-settings-menu/block-convert-button.js similarity index 82% rename from packages/editor/src/components/block-settings-menu/block-convert-button.js rename to packages/block-editor/src/components/block-settings-menu/block-convert-button.js index 992292e04a5f52..2ae65fed66cfd9 100644 --- a/packages/editor/src/components/block-settings-menu/block-convert-button.js +++ b/packages/block-editor/src/components/block-settings-menu/block-convert-button.js @@ -12,7 +12,7 @@ export default function BlockConvertButton( { shouldRender, onClick, small } ) { const label = __( 'Convert to Blocks' ); return ( { - const block = select( 'core/editor' ).getBlock( clientId ); + const block = select( 'core/block-editor' ).getBlock( clientId ); return { block, @@ -20,7 +20,7 @@ export default compose( }; } ), withDispatch( ( dispatch, { block } ) => ( { - onClick: () => dispatch( 'core/editor' ).replaceBlocks( + onClick: () => dispatch( 'core/block-editor' ).replaceBlocks( block.clientId, rawHandler( { HTML: getBlockContent( block ) } ), ), diff --git a/packages/editor/src/components/block-settings-menu/block-mode-toggle.js b/packages/block-editor/src/components/block-settings-menu/block-mode-toggle.js similarity index 83% rename from packages/editor/src/components/block-settings-menu/block-mode-toggle.js rename to packages/block-editor/src/components/block-settings-menu/block-mode-toggle.js index a8702f58f25748..8ba029fd1794c1 100644 --- a/packages/editor/src/components/block-settings-menu/block-mode-toggle.js +++ b/packages/block-editor/src/components/block-settings-menu/block-mode-toggle.js @@ -23,7 +23,7 @@ export function BlockModeToggle( { blockType, mode, onToggleMode, small = false return ( { - const { getBlock, getBlockMode } = select( 'core/editor' ); + const { getBlock, getBlockMode } = select( 'core/block-editor' ); const block = getBlock( clientId ); return { @@ -45,7 +45,7 @@ export default compose( [ } ), withDispatch( ( dispatch, { onToggle = noop, clientId } ) => ( { onToggleMode() { - dispatch( 'core/editor' ).toggleBlockMode( clientId ); + dispatch( 'core/block-editor' ).toggleBlockMode( clientId ); onToggle(); }, } ) ), diff --git a/packages/editor/src/components/block-settings-menu/block-settings-menu-first-item.js b/packages/block-editor/src/components/block-settings-menu/block-settings-menu-first-item.js similarity index 100% rename from packages/editor/src/components/block-settings-menu/block-settings-menu-first-item.js rename to packages/block-editor/src/components/block-settings-menu/block-settings-menu-first-item.js diff --git a/packages/editor/src/components/block-settings-menu/block-settings-menu-plugins-extension.js b/packages/block-editor/src/components/block-settings-menu/block-settings-menu-plugins-extension.js similarity index 100% rename from packages/editor/src/components/block-settings-menu/block-settings-menu-plugins-extension.js rename to packages/block-editor/src/components/block-settings-menu/block-settings-menu-plugins-extension.js diff --git a/packages/editor/src/components/block-settings-menu/block-unknown-convert-button.js b/packages/block-editor/src/components/block-settings-menu/block-unknown-convert-button.js similarity index 83% rename from packages/editor/src/components/block-settings-menu/block-unknown-convert-button.js rename to packages/block-editor/src/components/block-settings-menu/block-unknown-convert-button.js index e2bc0d3f8e0b7a..46eda45141ad12 100644 --- a/packages/editor/src/components/block-settings-menu/block-unknown-convert-button.js +++ b/packages/block-editor/src/components/block-settings-menu/block-unknown-convert-button.js @@ -12,7 +12,7 @@ import BlockConvertButton from './block-convert-button'; export default compose( withSelect( ( select, { clientId } ) => { - const block = select( 'core/editor' ).getBlock( clientId ); + const block = select( 'core/block-editor' ).getBlock( clientId ); return { block, @@ -20,7 +20,7 @@ export default compose( }; } ), withDispatch( ( dispatch, { block } ) => ( { - onClick: () => dispatch( 'core/editor' ).replaceBlocks( + onClick: () => dispatch( 'core/block-editor' ).replaceBlocks( block.clientId, rawHandler( { HTML: serialize( block ) } ) ), diff --git a/packages/block-editor/src/components/block-settings-menu/index.js b/packages/block-editor/src/components/block-settings-menu/index.js new file mode 100644 index 00000000000000..9c3e5b7b707ff5 --- /dev/null +++ b/packages/block-editor/src/components/block-settings-menu/index.js @@ -0,0 +1,147 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { castArray } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Fragment } from '@wordpress/element'; +import { Toolbar, Dropdown, NavigableMenu, MenuItem } from '@wordpress/components'; +import { withDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { shortcuts } from '../block-editor-keyboard-shortcuts'; +import BlockActions from '../block-actions'; +import BlockModeToggle from './block-mode-toggle'; +import ReusableBlockConvertButton from './reusable-block-convert-button'; +import ReusableBlockDeleteButton from './reusable-block-delete-button'; +import BlockHTMLConvertButton from './block-html-convert-button'; +import BlockUnknownConvertButton from './block-unknown-convert-button'; +import _BlockSettingsMenuFirstItem from './block-settings-menu-first-item'; +import _BlockSettingsMenuPluginsExtension from './block-settings-menu-plugins-extension'; + +export function BlockSettingsMenu( { clientIds, onSelect } ) { + const blockClientIds = castArray( clientIds ); + const count = blockClientIds.length; + const firstBlockClientId = blockClientIds[ 0 ]; + + return ( + + { ( { onDuplicate, onRemove, onInsertAfter, onInsertBefore, canDuplicate, isLocked } ) => ( + { + const toggleClassname = classnames( 'editor-block-settings-menu__toggle block-editor-block-settings-menu__toggle', { + 'is-opened': isOpen, + } ); + const label = isOpen ? __( 'Hide options' ) : __( 'More options' ); + + return ( + { + if ( count === 1 ) { + onSelect( firstBlockClientId ); + } + onToggle(); + }, + className: toggleClassname, + extraProps: { 'aria-expanded': isOpen }, + } ] } /> + ); + } } + renderContent={ ( { onClose } ) => ( + + <_BlockSettingsMenuFirstItem.Slot fillProps={ { onClose } } /> + { count === 1 && ( + + ) } + { count === 1 && ( + + ) } + { ! isLocked && canDuplicate && ( + + { __( 'Duplicate' ) } + + ) } + { ! isLocked && ( + + + { __( 'Insert Before' ) } + + + { __( 'Insert After' ) } + + + ) } + { count === 1 && ( + + ) } + + <_BlockSettingsMenuPluginsExtension.Slot fillProps={ { clientIds, onClose } } /> +
    + { count === 1 && ( + + ) } + { ! isLocked && ( + + { __( 'Remove Block' ) } + + ) } + + ) } + /> + ) } + + ); +} + +export default withDispatch( ( dispatch ) => { + const { selectBlock } = dispatch( 'core/block-editor' ); + + return { + onSelect( clientId ) { + selectBlock( clientId ); + }, + }; +} )( BlockSettingsMenu ); diff --git a/packages/block-editor/src/components/block-settings-menu/reusable-block-convert-button.js b/packages/block-editor/src/components/block-settings-menu/reusable-block-convert-button.js new file mode 100644 index 00000000000000..b699d59aa75723 --- /dev/null +++ b/packages/block-editor/src/components/block-settings-menu/reusable-block-convert-button.js @@ -0,0 +1,115 @@ +/** + * External dependencies + */ +import { noop, every } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Fragment } from '@wordpress/element'; +import { MenuItem } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { hasBlockSupport, isReusableBlock } from '@wordpress/blocks'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +export function ReusableBlockConvertButton( { + isVisible, + isReusable, + onConvertToStatic, + onConvertToReusable, +} ) { + if ( ! isVisible ) { + return null; + } + + return ( + + { ! isReusable && ( + + { __( 'Add to Reusable Blocks' ) } + + ) } + { isReusable && ( + + { __( 'Convert to Regular Block' ) } + + ) } + + ); +} + +export default compose( [ + withSelect( ( select, { clientIds } ) => { + const { + getBlocksByClientId, + canInsertBlockType, + } = select( 'core/block-editor' ); + const { + __experimentalGetReusableBlock: getReusableBlock, + } = select( 'core/editor' ); + const { canUser } = select( 'core' ); + + const blocks = getBlocksByClientId( clientIds ); + + const isReusable = ( + blocks.length === 1 && + blocks[ 0 ] && + isReusableBlock( blocks[ 0 ] ) && + !! getReusableBlock( blocks[ 0 ].attributes.ref ) + ); + + // Show 'Convert to Regular Block' when selected block is a reusable block + const isVisible = isReusable || ( + // Hide 'Add to Reusable Blocks' when reusable blocks are disabled + canInsertBlockType( 'core/block' ) && + + every( blocks, ( block ) => ( + // Guard against the case where a regular block has *just* been converted + !! block && + + // Hide 'Add to Reusable Blocks' on invalid blocks + block.isValid && + + // Hide 'Add to Reusable Blocks' when block doesn't support being made reusable + hasBlockSupport( block.name, 'reusable', true ) + ) ) && + + // Hide 'Add to Reusable Blocks' when current doesn't have permission to do that + !! canUser( 'create', 'blocks' ) + ); + + return { + isReusable, + isVisible, + }; + } ), + withDispatch( ( dispatch, { clientIds, onToggle = noop } ) => { + const { + __experimentalConvertBlockToReusable: convertBlockToReusable, + __experimentalConvertBlockToStatic: convertBlockToStatic, + } = dispatch( 'core/editor' ); + + return { + onConvertToStatic() { + if ( clientIds.length !== 1 ) { + return; + } + convertBlockToStatic( clientIds[ 0 ] ); + onToggle(); + }, + onConvertToReusable() { + convertBlockToReusable( clientIds ); + onToggle(); + }, + }; + } ), +] )( ReusableBlockConvertButton ); diff --git a/packages/block-editor/src/components/block-settings-menu/reusable-block-delete-button.js b/packages/block-editor/src/components/block-settings-menu/reusable-block-delete-button.js new file mode 100644 index 00000000000000..8c9abb9589c7d5 --- /dev/null +++ b/packages/block-editor/src/components/block-settings-menu/reusable-block-delete-button.js @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { compose } from '@wordpress/compose'; +import { MenuItem } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { isReusableBlock } from '@wordpress/blocks'; +import { withSelect, withDispatch } from '@wordpress/data'; + +export function ReusableBlockDeleteButton( { isVisible, isDisabled, onDelete } ) { + if ( ! isVisible ) { + return null; + } + + return ( + onDelete() } + > + { __( 'Remove from Reusable Blocks' ) } + + ); +} + +export default compose( [ + withSelect( ( select, { clientId } ) => { + const { getBlock } = select( 'core/block-editor' ); + const { canUser } = select( 'core' ); + const { __experimentalGetReusableBlock: getReusableBlock } = select( 'core/editor' ); + const block = getBlock( clientId ); + + const reusableBlock = block && isReusableBlock( block ) ? + getReusableBlock( block.attributes.ref ) : + null; + + return { + isVisible: !! reusableBlock && !! canUser( 'delete', 'blocks', reusableBlock.id ), + isDisabled: reusableBlock && reusableBlock.isTemporary, + }; + } ), + withDispatch( ( dispatch, { clientId, onToggle = noop }, { select } ) => { + const { + __experimentalDeleteReusableBlock: deleteReusableBlock, + } = dispatch( 'core/editor' ); + const { getBlock } = select( 'core/block-editor' ); + + return { + onDelete() { + // TODO: Make this a component or similar + // eslint-disable-next-line no-alert + const hasConfirmed = window.confirm( __( + 'Are you sure you want to delete this Reusable Block?\n\n' + + 'It will be permanently removed from all posts and pages that use it.' + ) ); + + if ( hasConfirmed ) { + const block = getBlock( clientId ); + deleteReusableBlock( block.attributes.ref ); + onToggle(); + } + }, + }; + } ), +] )( ReusableBlockDeleteButton ); diff --git a/packages/block-editor/src/components/block-settings-menu/style.scss b/packages/block-editor/src/components/block-settings-menu/style.scss new file mode 100644 index 00000000000000..39b62ec34b59de --- /dev/null +++ b/packages/block-editor/src/components/block-settings-menu/style.scss @@ -0,0 +1,59 @@ +.block-editor-block-settings-menu__toggle .dashicon { + transform: rotate(90deg); +} + +// Popout menu +.block-editor-block-settings-menu__popover { + &::before, + &::after { + margin-left: 2px; + } + + .block-editor-block-settings-menu__content { + padding: ($grid-size - $border-width) 0; + } + + .block-editor-block-settings-menu__separator { + margin-top: $grid-size; + margin-bottom: $grid-size; + margin-left: 0; + margin-right: 0; + border-top: $border-width solid $light-gray-500; + + // Check if the separator is the last child in the node and if so, hide itself + &:last-child { + display: none; + } + } + + .block-editor-block-settings-menu__title { + display: block; + padding: 6px; + color: $dark-gray-300; + } + + // Menu items + .block-editor-block-settings-menu__control { + width: 100%; + justify-content: flex-start; + background: none; + outline: none; + border-radius: 0; + color: $dark-gray-500; + text-align: left; + cursor: pointer; + @include menu-style__neutral; + + &:hover:not(:disabled):not([aria-disabled="true"]) { + @include menu-style__hover; + } + + &:focus:not(:disabled):not([aria-disabled="true"]) { + @include menu-style__focus; + } + + .dashicon { + margin-right: 5px; + } + } +} diff --git a/packages/block-editor/src/components/block-settings-menu/test/__snapshots__/reusable-block-delete-button.js.snap b/packages/block-editor/src/components/block-settings-menu/test/__snapshots__/reusable-block-delete-button.js.snap new file mode 100644 index 00000000000000..0e2d2d28a3216b --- /dev/null +++ b/packages/block-editor/src/components/block-settings-menu/test/__snapshots__/reusable-block-delete-button.js.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReusableBlockDeleteButton matches the snapshot 1`] = ` + + Remove from Reusable Blocks + +`; diff --git a/packages/editor/src/components/block-settings-menu/test/block-mode-toggle.js b/packages/block-editor/src/components/block-settings-menu/test/block-mode-toggle.js similarity index 100% rename from packages/editor/src/components/block-settings-menu/test/block-mode-toggle.js rename to packages/block-editor/src/components/block-settings-menu/test/block-mode-toggle.js diff --git a/packages/block-editor/src/components/block-settings-menu/test/reusable-block-convert-button.js b/packages/block-editor/src/components/block-settings-menu/test/reusable-block-convert-button.js new file mode 100644 index 00000000000000..c6fba313e31b34 --- /dev/null +++ b/packages/block-editor/src/components/block-settings-menu/test/reusable-block-convert-button.js @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import ShallowRenderer from 'react-test-renderer/shallow'; + +/** + * Internal dependencies + */ +import { ReusableBlockConvertButton } from '../reusable-block-convert-button'; + +describe( 'ReusableBlockConvertButton', () => { + function getShallowRenderOutput( element ) { + const renderer = new ShallowRenderer(); + renderer.render( element ); + return renderer.getRenderOutput(); + } + + it( 'should not render when isVisible false', () => { + const wrapper = getShallowRenderOutput( + + ); + expect( wrapper ).toBe( null ); + } ); + + it( 'should allow converting a static block to a reusable block', () => { + const onConvert = jest.fn(); + const wrapper = getShallowRenderOutput( + + ); + expect( wrapper.props.children[ 1 ] ).toBeFalsy(); + const button = wrapper.props.children[ 0 ]; + expect( button.props.children ).toBe( 'Add to Reusable Blocks' ); + button.props.onClick(); + expect( onConvert ).toHaveBeenCalled(); + } ); + + it( 'should allow converting a reusable block to static', () => { + const onConvert = jest.fn(); + const wrapper = getShallowRenderOutput( + + ); + expect( wrapper.props.children[ 0 ] ).toBeFalsy(); + const button = wrapper.props.children[ 1 ]; + expect( button.props.children ).toBe( 'Convert to Regular Block' ); + button.props.onClick(); + expect( onConvert ).toHaveBeenCalled(); + } ); +} ); diff --git a/packages/block-editor/src/components/block-settings-menu/test/reusable-block-delete-button.js b/packages/block-editor/src/components/block-settings-menu/test/reusable-block-delete-button.js new file mode 100644 index 00000000000000..39299becf29c24 --- /dev/null +++ b/packages/block-editor/src/components/block-settings-menu/test/reusable-block-delete-button.js @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import ShallowRenderer from 'react-test-renderer/shallow'; +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import { ReusableBlockDeleteButton } from '../reusable-block-delete-button'; + +describe( 'ReusableBlockDeleteButton', () => { + function getShallowRenderOutput( element ) { + const renderer = new ShallowRenderer(); + renderer.render( element ); + return renderer.getRenderOutput(); + } + + it( 'should not render when isVisible is false', () => { + const wrapper = getShallowRenderOutput( + + ); + + expect( wrapper ).toBe( null ); + } ); + + it( 'matches the snapshot', () => { + const wrapper = getShallowRenderOutput( + + ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'should allow deleting a reusable block', () => { + const onDelete = jest.fn(); + const wrapper = getShallowRenderOutput( + + ); + + wrapper.props.onClick(); + expect( onDelete ).toHaveBeenCalled(); + } ); +} ); diff --git a/packages/block-editor/src/components/block-styles/index.js b/packages/block-editor/src/components/block-styles/index.js new file mode 100644 index 00000000000000..215d9e545b69fb --- /dev/null +++ b/packages/block-editor/src/components/block-styles/index.js @@ -0,0 +1,168 @@ +/** + * 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 } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { BlockPreviewContent } 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, + name, + attributes, + type, + 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 ( +
    + { styles.map( ( style ) => { + const styleClassName = replaceActiveStyle( className, activeStyle, style ); + return ( +
    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 } + > +
    + +
    +
    + { style.label || style.name } +
    +
    + ); + } ) } +
    + ); +} + +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 { + name: block.name, + attributes: block.attributes, + 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/packages/block-editor/src/components/block-styles/style.scss b/packages/block-editor/src/components/block-styles/style.scss new file mode 100644 index 00000000000000..d818bcb15349b3 --- /dev/null +++ b/packages/block-editor/src/components/block-styles/style.scss @@ -0,0 +1,58 @@ +.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; + + &.is-active { + @include block-style__focus-active(); + box-shadow: 0 0 0 2px $dark-gray-500; + } + + &:focus { + @include block-style__focus-active(); + } + + &:hover { + @include block-style__hover; + } +} + +.block-editor-block-styles__item-preview { + outline: $border-width solid transparent; // Shown in Windows High Contrast mode. + border: 1px solid rgba($dark-gray-900, 0.2); + overflow: hidden; + padding: 0; + text-align: initial; + border-radius: $radius-round-rectangle; + display: flex; + height: 60px; + background: $white; + + // Actual preview contents. + .block-editor-block-preview__content { + transform: scale(0.7); + transform-origin: center center; + width: 100%; + + // Unset some of the styles that might be inherited from the editor style. + margin: 0; + padding: 0; + overflow: visible; + min-height: auto; + } +} + +.block-editor-block-styles__item-label { + text-align: center; + padding: 4px 2px; +} diff --git a/packages/editor/src/components/block-styles/test/index.js b/packages/block-editor/src/components/block-styles/test/index.js similarity index 100% rename from packages/editor/src/components/block-styles/test/index.js rename to packages/block-editor/src/components/block-styles/test/index.js diff --git a/packages/block-editor/src/components/block-switcher/index.js b/packages/block-editor/src/components/block-switcher/index.js new file mode 100644 index 00000000000000..371975e53d088e --- /dev/null +++ b/packages/block-editor/src/components/block-switcher/index.js @@ -0,0 +1,198 @@ +/** + * 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, hasChildBlocksWithInserterSupport } from '@wordpress/blocks'; +import { Component, Fragment } 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 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 ( + + + + + + ); + } + + return ( + { + 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 ( + + + + + + ) } + /> + + ); + } } + renderContent={ ( { onClose } ) => ( + + { hasBlockStyles && + + + + } + { possibleBlockTransformations.length !== 0 && + + ( { + id: destinationBlockType.name, + icon: destinationBlockType.icon, + title: destinationBlockType.title, + hasChildBlocksWithInserterSupport: hasChildBlocksWithInserterSupport( destinationBlockType.name ), + } ) ) } + onSelect={ ( item ) => { + onTransform( blocks, item.id ); + onClose(); + } } + /> + + } + + { ( hoveredClassName !== null ) && + + } + + ) } + /> + ); + } +} + +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/packages/editor/src/components/block-switcher/multi-blocks-switcher.js b/packages/block-editor/src/components/block-switcher/multi-blocks-switcher.js similarity index 85% rename from packages/editor/src/components/block-switcher/multi-blocks-switcher.js rename to packages/block-editor/src/components/block-switcher/multi-blocks-switcher.js index 25d9e8c339e7a1..8adae420dab998 100644 --- a/packages/editor/src/components/block-switcher/multi-blocks-switcher.js +++ b/packages/block-editor/src/components/block-switcher/multi-blocks-switcher.js @@ -19,7 +19,7 @@ export function MultiBlocksSwitcher( { isMultiBlockSelection, selectedBlockClien export default withSelect( ( select ) => { - const selectedBlockClientIds = select( 'core/editor' ).getMultiSelectedBlockClientIds(); + const selectedBlockClientIds = select( 'core/block-editor' ).getMultiSelectedBlockClientIds(); return { isMultiBlockSelection: selectedBlockClientIds.length > 1, selectedBlockClientIds, diff --git a/packages/block-editor/src/components/block-switcher/style.scss b/packages/block-editor/src/components/block-switcher/style.scss new file mode 100644 index 00000000000000..9431b74fe1f31f --- /dev/null +++ b/packages/block-editor/src/components/block-switcher/style.scss @@ -0,0 +1,138 @@ +.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 { + background: $light-gray-200; + 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; + } +} + +// 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); + } + + // 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(); + } +} + +.components-popover:not(.is-mobile).block-editor-block-switcher__popover .components-popover__content { + min-width: 300px; + max-width: 340px; +} + +.block-editor-block-switcher__popover .components-popover__content { + @include break-medium { + position: relative; + + .block-editor-block-preview { + border: $border-width solid $light-gray-500; + box-shadow: $shadow-popover; + background: $white; + position: absolute; + left: 100%; + top: -1px; + bottom: -1px; + width: 300px; + height: auto; + } + } + + // 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:not(.is-mobile) > .components-popover__content { + // Reset overflow to allow showing the preview on the left once an item is hovered. + overflow-y: visible; +} + +.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; +} diff --git a/packages/block-editor/src/components/block-switcher/test/__snapshots__/index.js.snap b/packages/block-editor/src/components/block-switcher/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000000..4680cbc0b0b18d --- /dev/null +++ b/packages/block-editor/src/components/block-switcher/test/__snapshots__/index.js.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BlockSwitcher should render disabled block switcher with multi block of different types when no transforms 1`] = ` + + + + + +`; + +exports[`BlockSwitcher should render enabled block switcher with multi block when transforms exist 1`] = ` + +`; + +exports[`BlockSwitcher should render switcher with blocks 1`] = ` + +`; diff --git a/packages/editor/src/components/block-switcher/test/__snapshots__/multi-blocks-switcher.js.snap b/packages/block-editor/src/components/block-switcher/test/__snapshots__/multi-blocks-switcher.js.snap similarity index 100% rename from packages/editor/src/components/block-switcher/test/__snapshots__/multi-blocks-switcher.js.snap rename to packages/block-editor/src/components/block-switcher/test/__snapshots__/multi-blocks-switcher.js.snap diff --git a/packages/block-editor/src/components/block-switcher/test/index.js b/packages/block-editor/src/components/block-switcher/test/index.js new file mode 100644 index 00000000000000..544d403872206b --- /dev/null +++ b/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: '

    How are you?

    ', + clientId: 'a1303fd6-3e60-4fff-a770-0e0ea656c5b9', + }; + + const textBlock = { + attributes: { + content: [ 'I am great!' ], + }, + isValid: true, + name: 'core/paragraph', + originalContent: '

    I am great!

    ', + clientId: 'b1303fdb-3e60-43faf-a770-2e1ea656c5b8', + }; + + const headingBlock2 = { + attributes: { + content: [ 'I am the greatest!' ], + level: 3, + }, + isValid: true, + name: 'core/heading', + originalContent: '

    I am the greatest!

    ', + 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( ); + + 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( ); + + 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( ); + + 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( ); + + 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( ); + 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(
    { getDropdown().props().renderContent( { onClose: onCloseStub } ) }
    ); + const blockList = content.find( 'BlockTypesList' ); + expect( blockList.prop( 'items' ) ).toHaveLength( 1 ); + } ); + } ); + } ); +} ); diff --git a/packages/editor/src/components/block-switcher/test/multi-blocks-switcher.js b/packages/block-editor/src/components/block-switcher/test/multi-blocks-switcher.js similarity index 100% rename from packages/editor/src/components/block-switcher/test/multi-blocks-switcher.js rename to packages/block-editor/src/components/block-switcher/test/multi-blocks-switcher.js diff --git a/packages/editor/src/components/block-title/README.md b/packages/block-editor/src/components/block-title/README.md similarity index 100% rename from packages/editor/src/components/block-title/README.md rename to packages/block-editor/src/components/block-title/README.md diff --git a/packages/block-editor/src/components/block-title/index.js b/packages/block-editor/src/components/block-title/index.js new file mode 100644 index 00000000000000..99519655419e12 --- /dev/null +++ b/packages/block-editor/src/components/block-title/index.js @@ -0,0 +1,41 @@ +/** + * 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 + * + * ``` + * + * @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/packages/editor/src/components/block-title/test/index.js b/packages/block-editor/src/components/block-title/test/index.js similarity index 100% rename from packages/editor/src/components/block-title/test/index.js rename to packages/block-editor/src/components/block-title/test/index.js diff --git a/packages/block-editor/src/components/block-toolbar/index.js b/packages/block-editor/src/components/block-toolbar/index.js new file mode 100644 index 00000000000000..8933a04665d966 --- /dev/null +++ b/packages/block-editor/src/components/block-toolbar/index.js @@ -0,0 +1,61 @@ +/** + * WordPress dependencies + */ +import { withSelect } from '@wordpress/data'; +import { Fragment } from '@wordpress/element'; + +/** + * 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 ( +
    + + +
    + ); + } + + return ( +
    + { mode === 'visual' && isValid && ( + + + + + + ) } + +
    + ); +} + +export default withSelect( ( select ) => { + const { + getSelectedBlockClientId, + getBlockMode, + getMultiSelectedBlockClientIds, + isBlockValid, + } = select( 'core/block-editor' ); + const selectedBlockClientId = getSelectedBlockClientId(); + const blockClientIds = selectedBlockClientId ? + [ selectedBlockClientId ] : + getMultiSelectedBlockClientIds(); + + return { + blockClientIds, + isValid: selectedBlockClientId ? isBlockValid( selectedBlockClientId ) : null, + mode: selectedBlockClientId ? getBlockMode( selectedBlockClientId ) : null, + }; +} )( BlockToolbar ); diff --git a/packages/block-editor/src/components/block-toolbar/style.scss b/packages/block-editor/src/components/block-toolbar/style.scss new file mode 100644 index 00000000000000..e0fbc6601ee369 --- /dev/null +++ b/packages/block-editor/src/components/block-toolbar/style.scss @@ -0,0 +1,43 @@ +.block-editor-block-toolbar { + display: flex; + flex-grow: 1; + width: 100%; + overflow: auto; // Allow horizontal scrolling on mobile. + position: relative; + transition: border-color 0.1s linear, box-shadow 0.1s linear; + border-left: $border-width solid $light-gray-800; + + @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; + } + + // 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; + } + } +} diff --git a/packages/block-editor/src/components/block-types-list/index.js b/packages/block-editor/src/components/block-types-list/index.js new file mode 100644 index 00000000000000..d2639e80f2f3c9 --- /dev/null +++ b/packages/block-editor/src/components/block-types-list/index.js @@ -0,0 +1,45 @@ +/** + * 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 */ +
      + { items && items.map( ( item ) => + { + onSelect( item ); + onHover( null ); + } } + onFocus={ () => onHover( item ) } + onMouseEnter={ () => onHover( item ) } + onMouseLeave={ () => onHover( null ) } + onBlur={ () => onHover( null ) } + isDisabled={ item.isDisabled } + title={ item.title } + /> + ) } + { children } +
    + /* eslint-enable jsx-a11y/no-redundant-roles */ + ); +} + +export default BlockTypesList; diff --git a/packages/block-editor/src/components/block-types-list/style.scss b/packages/block-editor/src/components/block-types-list/style.scss new file mode 100644 index 00000000000000..73872e11ed5e0c --- /dev/null +++ b/packages/block-editor/src/components/block-types-list/style.scss @@ -0,0 +1,7 @@ +.block-editor-block-types-list { + list-style: none; + padding: 2px 0; + overflow: hidden; + display: flex; + flex-wrap: wrap; +} diff --git a/packages/editor/src/components/color-palette/control.js b/packages/block-editor/src/components/color-palette/control.js similarity index 88% rename from packages/editor/src/components/color-palette/control.js rename to packages/block-editor/src/components/color-palette/control.js index d39bb961362c43..097cae030e9a3f 100644 --- a/packages/editor/src/components/color-palette/control.js +++ b/packages/block-editor/src/components/color-palette/control.js @@ -41,10 +41,10 @@ export function ColorPaletteControl( { return ( + Test Color + + + } +> + + +`; diff --git a/packages/editor/src/components/color-palette/test/control.js b/packages/block-editor/src/components/color-palette/test/control.js similarity index 100% rename from packages/editor/src/components/color-palette/test/control.js rename to packages/block-editor/src/components/color-palette/test/control.js diff --git a/packages/editor/src/components/color-palette/with-color-context.js b/packages/block-editor/src/components/color-palette/with-color-context.js similarity index 91% rename from packages/editor/src/components/color-palette/with-color-context.js rename to packages/block-editor/src/components/color-palette/with-color-context.js index 7112550219bbf4..1c32141f2a3f13 100644 --- a/packages/editor/src/components/color-palette/with-color-context.js +++ b/packages/block-editor/src/components/color-palette/with-color-context.js @@ -13,7 +13,7 @@ import { withSelect } from '@wordpress/data'; export default createHigherOrderComponent( withSelect( ( select, ownProps ) => { - const settings = select( 'core/editor' ).getEditorSettings(); + const settings = select( 'core/block-editor' ).getSettings(); const colors = ownProps.colors === undefined ? settings.colors : ownProps.colors; diff --git a/packages/block-editor/src/components/colors/index.js b/packages/block-editor/src/components/colors/index.js new file mode 100644 index 00000000000000..f6b6fac984db89 --- /dev/null +++ b/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/packages/block-editor/src/components/colors/test/__snapshots__/with-colors.js.snap b/packages/block-editor/src/components/colors/test/__snapshots__/with-colors.js.snap new file mode 100644 index 00000000000000..fb33dd8d00235b --- /dev/null +++ b/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`] = ` + +`; diff --git a/packages/block-editor/src/components/colors/test/with-colors.js b/packages/block-editor/src/components/colors/test/with-colors.js new file mode 100644 index 00000000000000..07c19ce0c11e7a --- /dev/null +++ b/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' )( () => ( +
    + ) ); + + const wrapper = shallow( + + ); + + 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 ) => ( + + ) ); + + const setAttributes = jest.fn(); + + const wrapper = mount( + + ); + + 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 ) => ( + + ) ); + + const setAttributes = jest.fn(); + + const wrapper = mount( + + ); + + wrapper.find( 'button' ).simulate( 'click' ); + expect( setAttributes ).toHaveBeenCalledWith( { backgroundColor: undefined, customBackgroundColor: '000000' } ); + } ); +} ); diff --git a/packages/editor/src/components/colors/utils.js b/packages/block-editor/src/components/colors/utils.js similarity index 100% rename from packages/editor/src/components/colors/utils.js rename to packages/block-editor/src/components/colors/utils.js diff --git a/packages/block-editor/src/components/colors/with-colors.js b/packages/block-editor/src/components/colors/with-colors.js new file mode 100644 index 00000000000000..516a5a023414fd --- /dev/null +++ b/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 ) => ( + +), '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 ( + + ); + } + }; + }, + ] ); +} + +/** + * 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/packages/block-editor/src/components/contrast-checker/index.js b/packages/block-editor/src/components/contrast-checker/index.js new file mode 100644 index 00000000000000..3a420448ae7d77 --- /dev/null +++ b/packages/block-editor/src/components/contrast-checker/index.js @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import tinycolor from 'tinycolor2'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Notice } from '@wordpress/components'; + +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; + } + 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.' ); + return ( +
    + + { msg } + +
    + ); +} + +export default ContrastChecker; diff --git a/packages/block-editor/src/components/contrast-checker/style.scss b/packages/block-editor/src/components/contrast-checker/style.scss new file mode 100644 index 00000000000000..b3b08d6230d05d --- /dev/null +++ b/packages/block-editor/src/components/contrast-checker/style.scss @@ -0,0 +1,3 @@ +.block-editor-contrast-checker > .components-notice { + margin: 0; +} diff --git a/packages/block-editor/src/components/contrast-checker/test/__snapshots__/index.js.snap b/packages/block-editor/src/components/contrast-checker/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000000..16bb2d6c050441 --- /dev/null +++ b/packages/block-editor/src/components/contrast-checker/test/__snapshots__/index.js.snap @@ -0,0 +1,167 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ContrastChecker should render component when the colors do not meet AA WCAG guidelines. 1`] = ` + +
    + +
    +
    + This color combination may be hard for people to read. Try using a brighter background color and/or a darker text color. +
    +
    +
    +
    +
    +`; + +exports[`ContrastChecker should render different message matching snapshot when background color has less brightness than text color. 1`] = ` + +
    + +
    +
    + This color combination may be hard for people to read. Try using a darker background color and/or a brighter text color. +
    +
    +
    +
    +
    +`; + +exports[`ContrastChecker should render messages when the textColor is valid, but the fallback backgroundColor conflicts. 1`] = ` + +
    + +
    +
    + This color combination may be hard for people to read. Try using a brighter background color and/or a darker text color. +
    +
    +
    +
    +
    +`; + +exports[`ContrastChecker should take into consideration the font size passed 1`] = ` + +
    + +
    +
    + This color combination may be hard for people to read. Try using a brighter background color and/or a darker text color. +
    +
    +
    +
    +
    +`; + +exports[`ContrastChecker should take into consideration wherever text is large or not 1`] = ` + +
    + +
    +
    + This color combination may be hard for people to read. Try using a brighter background color and/or a darker text color. +
    +
    +
    +
    +
    +`; + +exports[`ContrastChecker should use isLargeText to make decisions if both isLargeText and fontSize props are passed 1`] = ` + +
    + +
    +
    + This color combination may be hard for people to read. Try using a brighter background color and/or a darker text color. +
    +
    +
    +
    +
    +`; diff --git a/packages/editor/src/components/contrast-checker/test/index.js b/packages/block-editor/src/components/contrast-checker/test/index.js similarity index 100% rename from packages/editor/src/components/contrast-checker/test/index.js rename to packages/block-editor/src/components/contrast-checker/test/index.js diff --git a/packages/block-editor/src/components/copy-handler/index.js b/packages/block-editor/src/components/copy-handler/index.js new file mode 100644 index 00000000000000..3942eaae71b034 --- /dev/null +++ b/packages/block-editor/src/components/copy-handler/index.js @@ -0,0 +1,80 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { serialize } from '@wordpress/blocks'; +import { documentHasSelection } from '@wordpress/dom'; +import { withDispatch } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +class CopyHandler extends Component { + constructor() { + super( ...arguments ); + + this.onCopy = ( event ) => this.props.onCopy( event ); + this.onCut = ( event ) => this.props.onCut( event ); + } + + componentDidMount() { + document.addEventListener( 'copy', this.onCopy ); + document.addEventListener( 'cut', this.onCut ); + } + + componentWillUnmount() { + document.removeEventListener( 'copy', this.onCopy ); + document.removeEventListener( 'cut', this.onCut ); + } + + render() { + return null; + } +} + +export default compose( [ + withDispatch( ( dispatch, ownProps, { select } ) => { + const { + getBlocksByClientId, + getMultiSelectedBlockClientIds, + getSelectedBlockClientId, + hasMultiSelection, + } = select( 'core/block-editor' ); + const { removeBlocks } = dispatch( 'core/block-editor' ); + + const onCopy = ( event ) => { + const selectedBlockClientIds = getSelectedBlockClientId() ? + [ getSelectedBlockClientId() ] : + getMultiSelectedBlockClientIds(); + + 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 = getSelectedBlockClientId() ? + [ getSelectedBlockClientId() ] : + getMultiSelectedBlockClientIds(); + + removeBlocks( selectedBlockClientIds ); + } + }, + }; + } ), +] )( CopyHandler ); diff --git a/packages/block-editor/src/components/default-block-appender/index.js b/packages/block-editor/src/components/default-block-appender/index.js new file mode 100644 index 00000000000000..3baf90e4406097 --- /dev/null +++ b/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 ( +
    setState( { hovered: true } ) } + onMouseLeave={ () => setState( { hovered: false } ) } + > + + + { hovered && } + +
    + ); +} +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/packages/block-editor/src/components/default-block-appender/index.native.js b/packages/block-editor/src/components/default-block-appender/index.native.js new file mode 100644 index 00000000000000..eae900cc70362a --- /dev/null +++ b/packages/block-editor/src/components/default-block-appender/index.native.js @@ -0,0 +1,78 @@ +/** + * External dependencies + */ +import { TextInput, TouchableWithoutFeedback, View } from 'react-native'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { compose } from '@wordpress/compose'; +import { decodeEntities } from '@wordpress/html-entities'; +import { withSelect, withDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import styles from './style.scss'; + +export function DefaultBlockAppender( { + isLocked, + isVisible, + onAppend, + placeholder, +} ) { + if ( isLocked || ! isVisible ) { + return null; + } + + const value = decodeEntities( placeholder ) || __( 'Start writing…' ); + + return ( + + + + + + + + ); +} + +export default compose( + withSelect( ( select, ownProps ) => { + const { getBlockCount, getSettings, getTemplateLock } = select( 'core/block-editor' ); + + const isEmpty = ! getBlockCount( ownProps.rootClientId ); + const { bodyPlaceholder } = getSettings(); + + return { + isVisible: 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/packages/block-editor/src/components/default-block-appender/style.native.scss b/packages/block-editor/src/components/default-block-appender/style.native.scss new file mode 100644 index 00000000000000..fd3cd2f9afefb3 --- /dev/null +++ b/packages/block-editor/src/components/default-block-appender/style.native.scss @@ -0,0 +1,21 @@ +// @format + +@import "variables.scss"; +@import "colors.scss"; + +.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/packages/block-editor/src/components/default-block-appender/style.scss b/packages/block-editor/src/components/default-block-appender/style.scss new file mode 100644 index 00000000000000..d9728e201c1eca --- /dev/null +++ b/packages/block-editor/src/components/default-block-appender/style.scss @@ -0,0 +1,122 @@ +.block-editor-default-block-appender { + clear: both; // The appender doesn't scale well to sit next to floats, so clear them. + + 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; + resize: none; + + // 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; + } + } + + // Don't show the inserter until mousing over. + .block-editor-inserter__toggle:not([aria-expanded="true"]) { + opacity: 0; + transition: opacity 0.2s; + } + + &:hover { + .block-editor-inserter-with-shortcuts { + @include edit-post__fade-in-animation; + } + + .block-editor-inserter__toggle { + opacity: 1; + } + } + + // Dropzone. + .components-drop-zone__content-icon { + display: none; + } +} + +// 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; + } +} + +// 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 { + 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 { + right: 0; + display: flex; + } +} diff --git a/packages/block-editor/src/components/default-block-appender/test/__snapshots__/index.js.snap b/packages/block-editor/src/components/default-block-appender/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000000..9a09dae0d47674 --- /dev/null +++ b/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`] = ` +
    + + + +
    +`; + +exports[`DefaultBlockAppender should match snapshot 1`] = ` +
    + + + +
    +`; + +exports[`DefaultBlockAppender should optionally show without prompt 1`] = ` +
    + + + +
    +`; diff --git a/packages/editor/src/components/default-block-appender/test/index.js b/packages/block-editor/src/components/default-block-appender/test/index.js similarity index 100% rename from packages/editor/src/components/default-block-appender/test/index.js rename to packages/block-editor/src/components/default-block-appender/test/index.js diff --git a/packages/editor/src/components/font-sizes/font-size-picker.js b/packages/block-editor/src/components/font-sizes/font-size-picker.js similarity index 86% rename from packages/editor/src/components/font-sizes/font-size-picker.js rename to packages/block-editor/src/components/font-sizes/font-size-picker.js index 20e3fea9c2ad08..e2dbe3dba8134b 100644 --- a/packages/editor/src/components/font-sizes/font-size-picker.js +++ b/packages/block-editor/src/components/font-sizes/font-size-picker.js @@ -9,7 +9,7 @@ export default withSelect( const { disableCustomFontSizes, fontSizes, - } = select( 'core/editor' ).getEditorSettings(); + } = select( 'core/block-editor' ).getSettings(); return { disableCustomFontSizes, diff --git a/packages/editor/src/components/font-sizes/index.js b/packages/block-editor/src/components/font-sizes/index.js similarity index 100% rename from packages/editor/src/components/font-sizes/index.js rename to packages/block-editor/src/components/font-sizes/index.js diff --git a/packages/editor/src/components/font-sizes/index.native.js b/packages/block-editor/src/components/font-sizes/index.native.js similarity index 100% rename from packages/editor/src/components/font-sizes/index.native.js rename to packages/block-editor/src/components/font-sizes/index.native.js diff --git a/packages/block-editor/src/components/font-sizes/style.scss b/packages/block-editor/src/components/font-sizes/style.scss new file mode 100644 index 00000000000000..71949da0aab76d --- /dev/null +++ b/packages/block-editor/src/components/font-sizes/style.scss @@ -0,0 +1,3 @@ +.block-editor__container .components-popover.components-font-size-picker__dropdown-content.is-bottom { + z-index: z-index(".block-editor__container .components-popover.components-font-size-picker__dropdown-content.is-bottom"); +} diff --git a/packages/editor/src/components/font-sizes/utils.js b/packages/block-editor/src/components/font-sizes/utils.js similarity index 100% rename from packages/editor/src/components/font-sizes/utils.js rename to packages/block-editor/src/components/font-sizes/utils.js diff --git a/packages/editor/src/components/font-sizes/with-font-sizes.js b/packages/block-editor/src/components/font-sizes/with-font-sizes.js similarity index 98% rename from packages/editor/src/components/font-sizes/with-font-sizes.js rename to packages/block-editor/src/components/font-sizes/with-font-sizes.js index 3c9be5c1a4440f..23a8c76a11d25b 100644 --- a/packages/editor/src/components/font-sizes/with-font-sizes.js +++ b/packages/block-editor/src/components/font-sizes/with-font-sizes.js @@ -38,7 +38,7 @@ export default ( ...fontSizeNames ) => { return createHigherOrderComponent( compose( [ withSelect( ( select ) => { - const { fontSizes } = select( 'core/editor' ).getEditorSettings(); + const { fontSizes } = select( 'core/block-editor' ).getSettings(); return { fontSizes, }; diff --git a/packages/editor/src/components/ignore-nested-events/index.js b/packages/block-editor/src/components/ignore-nested-events/index.js similarity index 100% rename from packages/editor/src/components/ignore-nested-events/index.js rename to packages/block-editor/src/components/ignore-nested-events/index.js diff --git a/packages/editor/src/components/ignore-nested-events/test/index.js b/packages/block-editor/src/components/ignore-nested-events/test/index.js similarity index 100% rename from packages/editor/src/components/ignore-nested-events/test/index.js rename to packages/block-editor/src/components/ignore-nested-events/test/index.js diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js new file mode 100644 index 00000000000000..a43c53ea54f123 --- /dev/null +++ b/packages/block-editor/src/components/index.js @@ -0,0 +1,57 @@ +// Block Creation Components +export { default as Autocomplete } from './autocomplete'; +export { default as AlignmentToolbar } from './alignment-toolbar'; +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 BlockNavigationDropdown } from './block-navigation/dropdown'; +export { default as BlockIcon } from './block-icon'; +export { default as ColorPalette } from './color-palette'; +export { default as withColorContext } from './color-palette/with-color-context'; +export * from './colors'; +export { default as ContrastChecker } from './contrast-checker'; +export * from './font-sizes'; +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 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 MediaPlaceholder } from './media-placeholder'; +export { default as MediaUpload } from './media-upload'; +export { default as MediaUploadCheck } from './media-upload/check'; +export { default as URLInput } from './url-input'; +export { default as URLInputButton } from './url-input/button'; +export { default as URLPopover } from './url-popover'; + +// Content Related Components +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 BlockSelectionClearer } from './block-selection-clearer'; +export { default as BlockSettingsMenu } from './block-settings-menu'; +export { default as _BlockSettingsMenuFirstItem } from './block-settings-menu/block-settings-menu-first-item'; +export { default as _BlockSettingsMenuPluginsExtension } from './block-settings-menu/block-settings-menu-plugins-extension'; +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 Warning } from './warning'; +export { default as WritingFlow } from './writing-flow'; + +// State Related Components +export { default as BlockEditorProvider } from './provider'; diff --git a/packages/block-editor/src/components/inner-blocks/README.md b/packages/block-editor/src/components/inner-blocks/README.md new file mode 100644 index 00000000000000..8904d4b5cceba4 --- /dev/null +++ b/packages/block-editor/src/components/inner-blocks/README.md @@ -0,0 +1,114 @@ +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 ( +
    + +
    + ); + }, + + save() { + return ( +
    + +
    + ); + } +} ); +``` + +_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` + +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' ]; +... + +``` + +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 = []; +... + +``` + +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>` + +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](https://wordpress.org/gutenberg/handbook/templates/). + +```jsx +const TEMPLATE = [ [ 'core/columns', {}, [ + [ 'core/column', {}, [ + [ 'core/image' ], + ] ], + [ 'core/column', {}, [ + [ 'core/paragraph', { placeholder: 'Enter side content...' } ], + ] ], +] ] ]; +... + +``` + +The previous example creates an InnerBlocks area containing two columns one with an image and the other with a paragraph. + +### `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](https://wordpress.org/gutenberg/handbook/templates/#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. + +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. diff --git a/packages/block-editor/src/components/inner-blocks/index.js b/packages/block-editor/src/components/inner-blocks/index.js new file mode 100644 index 00000000000000..1dd6292ab324f0 --- /dev/null +++ b/packages/block-editor/src/components/inner-blocks/index.js @@ -0,0 +1,173 @@ +/** + * External dependencies + */ +import { pick, isEqual, map } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { withViewportMatch } from '@wordpress/viewport'; +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 BlockList from '../block-list'; +import { withBlockEditContext } from '../block-edit/context'; + +class InnerBlocks extends Component { + constructor() { + super( ...arguments ); + this.state = { + templateInProcess: !! this.props.template, + }; + this.updateNestedSettings(); + } + + getTemplateLock() { + const { + templateLock, + parentLock, + } = this.props; + return templateLock === undefined ? parentLock : templateLock; + } + + componentDidMount() { + const { innerBlocks } = this.props.block; + // only synchronize innerBlocks with template if innerBlocks are empty or a locking all exists + if ( innerBlocks.length === 0 || this.getTemplateLock() === 'all' ) { + this.synchronizeBlocksWithTemplate(); + } + if ( this.state.templateInProcess ) { + this.setState( { + templateInProcess: false, + } ); + } + } + + componentDidUpdate( prevProps ) { + const { template, block } = this.props; + const { innerBlocks } = block; + + this.updateNestedSettings(); + // only synchronize innerBlocks with template if innerBlocks are empty or a locking all exists + if ( innerBlocks.length === 0 || this.getTemplateLock() === '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, + } = this.props; + + const newSettings = { + allowedBlocks, + templateLock: this.getTemplateLock(), + }; + + if ( ! isShallowEqual( blockListSettings, newSettings ) ) { + updateNestedSettings( newSettings ); + } + } + + render() { + const { + clientId, + isSmallScreen, + isSelectedBlockInRoot, + } = this.props; + const { templateInProcess } = this.state; + + const classes = classnames( 'editor-inner-blocks block-editor-inner-blocks', { + 'has-overlay': isSmallScreen && ! isSelectedBlockInRoot, + } ); + + return ( +
    + { ! templateInProcess && ( + + ) } +
    + ); + } +} + +InnerBlocks = compose( [ + withBlockEditContext( ( context ) => pick( context, [ 'clientId' ] ) ), + withViewportMatch( { isSmallScreen: '< medium' } ), + withSelect( ( select, ownProps ) => { + const { + isBlockSelected, + hasSelectedInnerBlock, + getBlock, + getBlockListSettings, + getBlockRootClientId, + getTemplateLock, + } = select( 'core/block-editor' ); + const { clientId } = ownProps; + const rootClientId = getBlockRootClientId( clientId ); + return { + isSelectedBlockInRoot: isBlockSelected( clientId ) || hasSelectedInnerBlock( clientId ), + block: getBlock( clientId ), + blockListSettings: getBlockListSettings( clientId ), + parentLock: getTemplateLock( rootClientId ), + }; + } ), + withDispatch( ( dispatch, ownProps ) => { + const { + replaceBlocks, + insertBlocks, + updateBlockListSettings, + } = dispatch( 'core/block-editor' ); + const { block, clientId, templateInsertUpdatesSelection = true } = ownProps; + + return { + replaceInnerBlocks( blocks ) { + const clientIds = map( block.innerBlocks, 'clientId' ); + if ( clientIds.length ) { + replaceBlocks( clientIds, blocks ); + } else { + insertBlocks( blocks, undefined, clientId, templateInsertUpdatesSelection ); + } + }, + updateNestedSettings( settings ) { + dispatch( updateBlockListSettings( clientId, settings ) ); + }, + }; + } ), +] )( InnerBlocks ); + +InnerBlocks.Content = withBlockContentContext( + ( { BlockContent } ) => +); + +export default InnerBlocks; diff --git a/packages/block-editor/src/components/inner-blocks/style.scss b/packages/block-editor/src/components/inner-blocks/style.scss new file mode 100644 index 00000000000000..f4218ef0667ebe --- /dev/null +++ b/packages/block-editor/src/components/inner-blocks/style.scss @@ -0,0 +1,9 @@ +.block-editor-inner-blocks.has-overlay::after { + content: ""; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: z-index(".block-editor-inner-blocks__small-screen-overlay:after"); +} diff --git a/packages/editor/src/components/inner-blocks/test/__snapshots__/index.js.snap b/packages/block-editor/src/components/inner-blocks/test/__snapshots__/index.js.snap similarity index 100% rename from packages/editor/src/components/inner-blocks/test/__snapshots__/index.js.snap rename to packages/block-editor/src/components/inner-blocks/test/__snapshots__/index.js.snap diff --git a/packages/editor/src/components/inner-blocks/test/index.js b/packages/block-editor/src/components/inner-blocks/test/index.js similarity index 100% rename from packages/editor/src/components/inner-blocks/test/index.js rename to packages/block-editor/src/components/inner-blocks/test/index.js diff --git a/packages/block-editor/src/components/inserter-list-item/index.js b/packages/block-editor/src/components/inserter-list-item/index.js new file mode 100644 index 00000000000000..69d9e047418f3c --- /dev/null +++ b/packages/block-editor/src/components/inserter-list-item/index.js @@ -0,0 +1,69 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import BlockIcon from '../block-icon'; + +function InserterListItem( { + icon, + hasChildBlocksWithInserterSupport, + onClick, + isDisabled, + title, + className, + ...props +} ) { + const itemIconStyle = icon ? { + backgroundColor: icon.background, + color: icon.foreground, + } : {}; + const itemIconStackStyle = icon && icon.shadowColor ? { + backgroundColor: icon.shadowColor, + } : {}; + + return ( +
  • + +
  • + ); +} + +export default InserterListItem; diff --git a/packages/block-editor/src/components/inserter-list-item/style.scss b/packages/block-editor/src/components/inserter-list-item/style.scss new file mode 100644 index 00000000000000..895f9c39b7866e --- /dev/null +++ b/packages/block-editor/src/components/inserter-list-item/style.scss @@ -0,0 +1,114 @@ +.block-editor-block-types-list__list-item { + display: block; + width: 33.33%; + padding: 0 4px; + 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; + 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; + 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: currentColor; + } + } + + &:active, + &.is-active, + &:focus { + position: relative; + + // Show the focus style in the icon inside instead. + outline: none; + @include block-style__focus-active(); + + .block-editor-block-types-list__item-icon, + .block-editor-block-types-list__item-title { + color: currentColor; + } + } + } +} + +.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; + + .block-editor-block-icon { + margin-left: auto; + margin-right: auto; + } + + svg { + transition: all 0.15s ease-out; + } +} + +.block-editor-block-types-list__item-title { + padding: 4px 2px 8px; +} + +.block-editor-block-types-list__item-has-children { + .block-editor-block-types-list__item-icon { + background: $white; + margin-right: 3px; + margin-bottom: 6px; + padding: 9px 20px 9px; + position: relative; + top: -2px; + left: -2px; + box-shadow: 0 0 0 1px $light-gray-500; + } + + // Show a "stacked card" below an item that has children. + .block-editor-block-types-list__item-icon-stack { + display: block; + background: $white; + box-shadow: 0 0 0 1px $light-gray-500; + width: 100%; + height: 100%; + position: absolute; + z-index: -1; // Show below the card as a shadow + bottom: -6px; + right: -6px; + border-radius: 4px; + } +} diff --git a/packages/block-editor/src/components/inserter-with-shortcuts/index.js b/packages/block-editor/src/components/inserter-with-shortcuts/index.js new file mode 100644 index 00000000000000..71adf33922bff5 --- /dev/null +++ b/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 ( +
    + { itemsWithoutDefaultBlock.map( ( item ) => ( + onInsert( item ) } + // translators: %s: block title/name to be added + label={ sprintf( __( 'Add %s' ), item.title ) } + icon={ ( + + ) } + /> + ) ) } +
    + ); +} + +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/packages/block-editor/src/components/inserter-with-shortcuts/style.scss b/packages/block-editor/src/components/inserter-with-shortcuts/style.scss new file mode 100644 index 00000000000000..ef2d9e82ccebe5 --- /dev/null +++ b/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/packages/editor/src/components/inserter/child-blocks.js b/packages/block-editor/src/components/inserter/child-blocks.js similarity index 82% rename from packages/editor/src/components/inserter/child-blocks.js rename to packages/block-editor/src/components/inserter/child-blocks.js index 312e2cc69ebe9c..53590af3a1ee72 100644 --- a/packages/editor/src/components/inserter/child-blocks.js +++ b/packages/block-editor/src/components/inserter/child-blocks.js @@ -12,9 +12,9 @@ import BlockIcon from '../block-icon'; function ChildBlocks( { rootBlockIcon, rootBlockTitle, items, ...props } ) { return ( -
    +
    { ( rootBlockIcon || rootBlockTitle ) && ( -
    +
    { rootBlockTitle &&

    { rootBlockTitle }

    }
    @@ -32,7 +32,7 @@ export default compose( } = select( 'core/blocks' ); const { getBlockName, - } = select( 'core/editor' ); + } = select( 'core/block-editor' ); const rootBlockName = getBlockName( rootClientId ); const rootBlockType = getBlockType( rootBlockName ); return { diff --git a/packages/block-editor/src/components/inserter/index.js b/packages/block-editor/src/components/inserter/index.js new file mode 100644 index 00000000000000..48a28517ebde1b --- /dev/null +++ b/packages/block-editor/src/components/inserter/index.js @@ -0,0 +1,121 @@ +/** + * 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 } ) => ( + +); + +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 {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 {Function} options.onClose Callback to invoke when dropdown is + * closed. + * + * @return {WPElement} Dropdown content element. + */ + renderContent( { onClose } ) { + const { rootClientId, clientId, isAppender } = this.props; + + return ( + + ); + } + + render() { + const { position, title } = this.props; + + return ( + + ); + } +} + +export default compose( [ + withSelect( ( select, { rootClientId } ) => { + const { + hasInserterItems, + } = select( 'core/block-editor' ); + + // The title should be removed from the inserter + // or replaced by a prop passed to the inserter. + const { + getEditedPostAttribute, + } = select( 'core/editor' ); + + return { + title: getEditedPostAttribute( 'title' ), + hasItems: hasInserterItems( rootClientId ), + }; + } ), + ifCondition( ( { hasItems } ) => hasItems ), +] )( Inserter ); diff --git a/packages/block-editor/src/components/inserter/menu.js b/packages/block-editor/src/components/inserter/menu.js new file mode 100644 index 00000000000000..bf659f789d1881 --- /dev/null +++ b/packages/block-editor/src/components/inserter/menu.js @@ -0,0 +1,464 @@ +/** + * External dependencies + */ +import { + filter, + find, + findIndex, + flow, + groupBy, + isEmpty, + map, + some, + sortBy, + without, + includes, + deburr, +} from 'lodash'; +import scrollIntoView from 'dom-scroll-into-view'; + +/** + * WordPress dependencies + */ +import { __, _n, _x, sprintf } from '@wordpress/i18n'; +import { Component, createRef } from '@wordpress/element'; +import { withSpokenMessages, PanelBody } from '@wordpress/components'; +import { + getCategories, + isReusableBlock, + createBlock, + isUnmodifiedDefaultBlock, +} 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 ChildBlocks from './child-blocks'; + +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 + // eslint-disable-next-line react/no-find-dom-node + 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 } = this.props; + const { + childItems, + hoveredItem, + itemsPerCategory, + openPanels, + reusableItems, + suggestedItems, + } = this.state; + const isPanelOpen = ( panel ) => openPanels.indexOf( panel ) !== -1; + + // 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 ( +
    + + + +
    + + + + { !! suggestedItems.length && + + + + } + + { map( getCategories(), ( category ) => { + const categoryItems = itemsPerCategory[ category.slug ]; + if ( ! categoryItems || ! categoryItems.length ) { + return null; + } + return ( + + + + ); + } ) } + + { !! reusableItems.length && ( + + + + { __( 'Manage All Reusable Blocks' ) } + + + ) } + { isEmpty( suggestedItems ) && isEmpty( reusableItems ) && isEmpty( itemsPerCategory ) && ( +

    { __( 'No blocks found.' ) }

    + ) } +
    + + { hoveredItem && isReusableBlock( hoveredItem ) && + + } +
    + ); + /* eslint-enable jsx-a11y/no-autofocus, jsx-a11y/no-noninteractive-element-interactions */ + } +} + +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' ); + + // 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(); + }, + }; + } ), + withSpokenMessages, + withInstanceId, + withSafeTimeout +)( InserterMenu ); diff --git a/packages/block-editor/src/components/inserter/style.scss b/packages/block-editor/src/components/inserter/style.scss new file mode 100644 index 00000000000000..41f53d25e00e38 --- /dev/null +++ b/packages/block-editor/src/components/inserter/style.scss @@ -0,0 +1,141 @@ +$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; +} + +.block-editor-inserter__menu { + width: auto; + display: flex; + flex-direction: column; + height: 100%; + @include break-medium { + width: 400px; + position: relative; + + .block-editor-block-preview { + border: $border-width solid $light-gray-500; + box-shadow: $shadow-popover; + background: $white; + position: absolute; + left: 100%; + top: -1px; + bottom: -1px; + width: 300px; + } + } +} + +.block-editor-inserter__inline-elements { + margin-top: -1px; +} + +.block-editor-inserter__menu.is-bottom::after { + border-bottom-color: $white; +} + +.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; + } +} diff --git a/packages/block-editor/src/components/inserter/test/menu.js b/packages/block-editor/src/components/inserter/test/menu.js new file mode 100644 index 00000000000000..421b76d6b3593f --- /dev/null +++ b/packages/block-editor/src/components/inserter/test/menu.js @@ -0,0 +1,372 @@ +/** + * 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( + + ); +}; + +const initializeMenuDefaultStateAndReturnElement = ( propOverrides ) => { + const wrapper = getWrapperForProps( propOverrides ); + /* eslint-disable react/no-find-dom-node */ + return ReactDOM.findDOMNode( wrapper ); + /* eslint-enable react/no-find-dom-node */ +}; + +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/packages/editor/src/components/inspector-advanced-controls/index.js b/packages/block-editor/src/components/inspector-advanced-controls/index.js similarity index 100% rename from packages/editor/src/components/inspector-advanced-controls/index.js rename to packages/block-editor/src/components/inspector-advanced-controls/index.js diff --git a/packages/editor/src/components/inspector-controls/README.md b/packages/block-editor/src/components/inspector-controls/README.md similarity index 100% rename from packages/editor/src/components/inspector-controls/README.md rename to packages/block-editor/src/components/inspector-controls/README.md diff --git a/packages/editor/src/components/inspector-controls/index.js b/packages/block-editor/src/components/inspector-controls/index.js similarity index 100% rename from packages/editor/src/components/inspector-controls/index.js rename to packages/block-editor/src/components/inspector-controls/index.js diff --git a/packages/block-editor/src/components/media-placeholder/README.md b/packages/block-editor/src/components/media-placeholder/README.md new file mode 100644 index 00000000000000..2890d2f1f162a1 --- /dev/null +++ b/packages/block-editor/src/components/media-placeholder/README.md @@ -0,0 +1,54 @@ +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 = { + setAttributes( { theImage: el.url } ); + } + } + allowedTypes = { [ 'image' ] } + multiple = { false } + labels = { { title: 'The Image' } } + />; + ... + } +``` + +## 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/packages/block-editor/src/components/media-placeholder/index.js b/packages/block-editor/src/components/media-placeholder/index.js new file mode 100644 index 00000000000000..d972aeca06bd7e --- /dev/null +++ b/packages/block-editor/src/components/media-placeholder/index.js @@ -0,0 +1,277 @@ +/** + * External dependencies + */ +import { every, get, noop, startsWith, defaultTo } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { + Button, + FormFileUpload, + Placeholder, + DropZone, + IconButton, + withFilters, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { Component, Fragment } 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 } ) => ( + +
    + + + +
    +); + +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 { onSelect, multiple, onError, allowedTypes, mediaUpload } = this.props; + const setMedia = multiple ? onSelect : ( [ media ] ) => onSelect( media ); + mediaUpload( { + allowedTypes, + filesList: files, + onFileChange: setMedia, + onError, + } ); + } + + openURLInput() { + this.setState( { isURLInputVisible: true } ); + } + + closeURLInput() { + this.setState( { isURLInputVisible: false } ); + } + + render() { + const { + accept, + icon, + className, + labels = {}, + onSelect, + value = {}, + onSelectURL, + onHTMLDrop = noop, + multiple = false, + notices, + allowedTypes = [], + hasUploadPermissions, + mediaUpload, + } = this.props; + + const { + isURLInputVisible, + src, + } = this.state; + + let instructions = labels.instructions || ''; + let title = labels.title || ''; + + if ( ! hasUploadPermissions && ! onSelectURL ) { + instructions = __( 'To edit this block, you need permission to upload media.' ); + } + + if ( ! instructions || ! title ) { + 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 ) { + if ( hasUploadPermissions ) { + instructions = __( 'Drag a media file, upload a new one or select a file from your library.' ); + + if ( isAudio ) { + instructions = __( 'Drag an audio, upload a new one or select a file from your library.' ); + } else if ( isImage ) { + instructions = __( 'Drag an image, upload a new one or select a file from your library.' ); + } else if ( isVideo ) { + instructions = __( 'Drag a video, upload a new one or select a file from your library.' ); + } + } else if ( ! hasUploadPermissions && onSelectURL ) { + instructions = __( 'Given your current role, you can only link a media file, you cannot upload.' ); + + if ( isAudio ) { + instructions = __( 'Given your current role, you can only link an audio, you cannot upload.' ); + } else if ( isImage ) { + instructions = __( 'Given your current role, you can only link an image, you cannot upload.' ); + } else if ( isVideo ) { + instructions = __( 'Given your current role, you can only link a video, you cannot upload.' ); + } + } + } + + if ( ! title ) { + title = __( 'Media' ); + + if ( isAudio ) { + title = __( 'Audio' ); + } else if ( isImage ) { + title = __( 'Image' ); + } else if ( isVideo ) { + title = __( 'Video' ); + } + } + } + + return ( + + + { !! mediaUpload && ( + + + + { __( 'Upload' ) } + + + ) } + ( + + ) } + /> + + { onSelectURL && ( +
    + + { isURLInputVisible && ( + + ) } +
    + ) } +
    + ); + } +} + +const applyWithSelect = withSelect( ( select ) => { + const { canUser } = select( 'core' ); + const { getSettings } = select( 'core/block-editor' ); + + return { + hasUploadPermissions: defaultTo( canUser( 'create', 'media' ), true ), + mediaUpload: getSettings().__experimentalMediaUpload, + }; +} ); + +export default compose( + applyWithSelect, + withFilters( 'editor.MediaPlaceholder' ), +)( MediaPlaceholder ); diff --git a/packages/block-editor/src/components/media-placeholder/index.native.js b/packages/block-editor/src/components/media-placeholder/index.native.js new file mode 100644 index 00000000000000..1bd983c37699a7 --- /dev/null +++ b/packages/block-editor/src/components/media-placeholder/index.native.js @@ -0,0 +1,33 @@ +/** + * External dependencies + */ +import { View, Text, TouchableWithoutFeedback } from 'react-native'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Dashicon } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import styles from './styles.scss'; + +function MediaPlaceholder( props ) { + return ( + + + + + { __( 'Image' ) } + + + { __( 'CHOOSE IMAGE' ) } + + + + ); +} + +export default MediaPlaceholder; diff --git a/packages/block-editor/src/components/media-placeholder/style.scss b/packages/block-editor/src/components/media-placeholder/style.scss new file mode 100644 index 00000000000000..7cc50c523885af --- /dev/null +++ b/packages/block-editor/src/components/media-placeholder/style.scss @@ -0,0 +1,47 @@ +.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; + } +} + +.components-form-file-upload .block-editor-media-placeholder__button { + margin-right: $grid-size-small; +} diff --git a/packages/block-editor/src/components/media-placeholder/styles.native.scss b/packages/block-editor/src/components/media-placeholder/styles.native.scss new file mode 100644 index 00000000000000..d3001491eb73aa --- /dev/null +++ b/packages/block-editor/src/components/media-placeholder/styles.native.scss @@ -0,0 +1,27 @@ +.emptyStateContainer { + flex: 1; + height: 142; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: #e9eff3; + 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: #0087be; + font-size: 14; + font-weight: 500; +} diff --git a/packages/editor/src/components/media-placeholder/test/index.js b/packages/block-editor/src/components/media-placeholder/test/index.js similarity index 100% rename from packages/editor/src/components/media-placeholder/test/index.js rename to packages/block-editor/src/components/media-placeholder/test/index.js diff --git a/packages/block-editor/src/components/media-upload/README.md b/packages/block-editor/src/components/media-upload/README.md new file mode 100644 index 00000000000000..eb2f75cbdd8af2 --- /dev/null +++ b/packages/block-editor/src/components/media-upload/README.md @@ -0,0 +1,113 @@ +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 ( + + console.log( 'selected ' + media.length ) } + allowedTypes={ ALLOWED_MEDIA_TYPES } + value={ mediaId } + render={ ( { open } ) => ( + + ) } + /> + + ); +} +``` + +## 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 + +### onSelect + +Callback called when the media modal is closed, 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 + +## 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/packages/block-editor/src/components/media-upload/check.js b/packages/block-editor/src/components/media-upload/check.js new file mode 100644 index 00000000000000..5dde4c69fa827c --- /dev/null +++ b/packages/block-editor/src/components/media-upload/check.js @@ -0,0 +1,21 @@ +/** + * External dependencies + */ +import { defaultTo } from 'lodash'; + +/** + * WordPress dependencies + */ +import { withSelect } from '@wordpress/data'; + +export function MediaUploadCheck( { hasUploadPermissions, fallback = null, children } ) { + return hasUploadPermissions ? children : fallback; +} + +export default withSelect( ( select ) => { + const { canUser } = select( 'core' ); + + return { + hasUploadPermissions: defaultTo( canUser( 'create', 'media' ), true ), + }; +} )( MediaUploadCheck ); diff --git a/packages/editor/src/components/media-upload/index.js b/packages/block-editor/src/components/media-upload/index.js similarity index 100% rename from packages/editor/src/components/media-upload/index.js rename to packages/block-editor/src/components/media-upload/index.js diff --git a/packages/block-editor/src/components/multi-select-scroll-into-view/index.js b/packages/block-editor/src/components/multi-select-scroll-into-view/index.js new file mode 100644 index 00000000000000..e49a90a520cbee --- /dev/null +++ b/packages/block-editor/src/components/multi-select-scroll-into-view/index.js @@ -0,0 +1,66 @@ +/** + * 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. + * + * @return {void} + */ + 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/packages/block-editor/src/components/multi-selection-inspector/index.js b/packages/block-editor/src/components/multi-selection-inspector/index.js new file mode 100644 index 00000000000000..bc0bcef9558837 --- /dev/null +++ b/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 ( +
    + + } showColors /> +
    +
    + { + /* translators: %d: number of blocks */ + sprintf( _n( '%d block', '%d blocks', blocks.length ), blocks.length ) + } +
    +
    + { + /* translators: %d: number of words */ + sprintf( _n( '%d word', '%d words', words ), words ) + } +
    +
    +
    + ); +} + +export default withSelect( ( select ) => { + const { getMultiSelectedBlocks } = select( 'core/block-editor' ); + return { + blocks: getMultiSelectedBlocks(), + }; +} )( MultiSelectionInspector ); diff --git a/packages/block-editor/src/components/multi-selection-inspector/style.scss b/packages/block-editor/src/components/multi-selection-inspector/style.scss new file mode 100644 index 00000000000000..bd81710ebfcaef --- /dev/null +++ b/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/packages/editor/src/components/navigable-toolbar/index.js b/packages/block-editor/src/components/navigable-toolbar/index.js similarity index 100% rename from packages/editor/src/components/navigable-toolbar/index.js rename to packages/block-editor/src/components/navigable-toolbar/index.js diff --git a/packages/editor/src/components/observe-typing/README.md b/packages/block-editor/src/components/observe-typing/README.md similarity index 100% rename from packages/editor/src/components/observe-typing/README.md rename to packages/block-editor/src/components/observe-typing/README.md diff --git a/packages/block-editor/src/components/observe-typing/index.js b/packages/block-editor/src/components/observe-typing/index.js new file mode 100644 index 00000000000000..6998eb771bf9ee --- /dev/null +++ b/packages/block-editor/src/components/observe-typing/index.js @@ -0,0 +1,219 @@ +/** + * 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 ( +
    + { children } +
    + ); + /* eslint-enable jsx-a11y/no-static-element-interactions */ + } +} + +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/packages/block-editor/src/components/panel-color-settings/index.js b/packages/block-editor/src/components/panel-color-settings/index.js new file mode 100644 index 00000000000000..aaa684579a4a88 --- /dev/null +++ b/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 ( + + ); + } + ); +}; + +// 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 = ( + + { title } + { renderColorIndicators( colorSettings, colors ) } + + ); + + return ( + + { colorSettings.map( ( settings, index ) => ( + + ) ) } + + { children } + + ); + } +); + +export default withColorContext( PanelColorSettings ); diff --git a/packages/block-editor/src/components/panel-color-settings/style.scss b/packages/block-editor/src/components/panel-color-settings/style.scss new file mode 100644 index 00000000000000..3a88a7ecb7adbf --- /dev/null +++ b/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/packages/block-editor/src/components/panel-color-settings/test/__snapshots__/index.js.snap b/packages/block-editor/src/components/panel-color-settings/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000000..569bd074451bdf --- /dev/null +++ b/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`] = ` + +`; + +exports[`PanelColorSettings matches the snapshot 2`] = ` + + Test Title + + + + } +> + + + +`; + +exports[`PanelColorSettings should render a color panel if at least one setting specifies some colors to choose 1`] = ` + +`; + +exports[`PanelColorSettings should render a color panel if at least one setting specifies some colors to choose 2`] = ` + + Test Title + + + + } +> + + + +`; + +exports[`PanelColorSettings should render a color panel if at least one setting supports custom colors 1`] = ` + +`; + +exports[`PanelColorSettings should render a color panel if at least one setting supports custom colors 2`] = ` + + Test Title + + + + } +> + + + +`; diff --git a/packages/editor/src/components/panel-color-settings/test/index.js b/packages/block-editor/src/components/panel-color-settings/test/index.js similarity index 100% rename from packages/editor/src/components/panel-color-settings/test/index.js rename to packages/block-editor/src/components/panel-color-settings/test/index.js diff --git a/packages/editor/src/components/plain-text/README.md b/packages/block-editor/src/components/plain-text/README.md similarity index 100% rename from packages/editor/src/components/plain-text/README.md rename to packages/block-editor/src/components/plain-text/README.md diff --git a/packages/block-editor/src/components/plain-text/index.js b/packages/block-editor/src/components/plain-text/index.js new file mode 100644 index 00000000000000..a186c53ca6a55e --- /dev/null +++ b/packages/block-editor/src/components/plain-text/index.js @@ -0,0 +1,17 @@ +/** + * External dependencies + */ +import TextareaAutosize from 'react-autosize-textarea'; +import classnames from 'classnames'; + +function PlainText( { onChange, className, ...props } ) { + return ( + onChange( event.target.value ) } + { ...props } + /> + ); +} + +export default PlainText; diff --git a/packages/block-editor/src/components/plain-text/index.native.js b/packages/block-editor/src/components/plain-text/index.native.js new file mode 100644 index 00000000000000..9dd7eba3461528 --- /dev/null +++ b/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 ( + 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/packages/block-editor/src/components/plain-text/style.native.scss b/packages/block-editor/src/components/plain-text/style.native.scss new file mode 100644 index 00000000000000..97a21c5dd37a71 --- /dev/null +++ b/packages/block-editor/src/components/plain-text/style.native.scss @@ -0,0 +1,11 @@ +@import "variables.scss"; + +.block-editor-plain-text { + font-family: $default-regular-font; + box-shadow: none; + + border-width: 0; + + padding: 0; + margin: 0; +} diff --git a/packages/block-editor/src/components/plain-text/style.scss b/packages/block-editor/src/components/plain-text/style.scss new file mode 100644 index 00000000000000..abcfaf5c8c1de4 --- /dev/null +++ b/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/packages/block-editor/src/components/preserve-scroll-in-reorder/index.js b/packages/block-editor/src/components/preserve-scroll-in-reorder/index.js new file mode 100644 index 00000000000000..e791dea222655e --- /dev/null +++ b/packages/block-editor/src/components/preserve-scroll-in-reorder/index.js @@ -0,0 +1,83 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { withSelect } from '@wordpress/data'; +import { getScrollContainer } from '@wordpress/dom'; + +/** + * Internal dependencies + */ +import { getBlockDOMNode } from '../../utils/dom'; + +/** + * Non-visual component which preserves offset of selected block within nearest + * scrollable container while reordering. + * + * @example + * + * ```jsx + * + * ``` + */ +class PreserveScrollInReorder extends Component { + getSnapshotBeforeUpdate( prevProps ) { + const { blockOrder, selectionStart } = this.props; + if ( blockOrder !== prevProps.blockOrder && selectionStart ) { + return this.getOffset( selectionStart ); + } + + return null; + } + + componentDidUpdate( prevProps, prevState, snapshot ) { + if ( snapshot ) { + this.restorePreviousOffset( snapshot ); + } + } + + /** + * Given the block client ID of the start of the selection, saves the + * block's top offset as an instance property before a reorder is to occur. + * + * @param {string} selectionStart Client ID of selected block. + * + * @return {number?} The scroll offset. + */ + getOffset( selectionStart ) { + const blockNode = getBlockDOMNode( selectionStart ); + if ( ! blockNode ) { + return null; + } + + return blockNode.getBoundingClientRect().top; + } + + /** + * After a block reordering, restores the previous viewport top offset. + * + * @param {number} offset The scroll offset. + */ + restorePreviousOffset( offset ) { + const { selectionStart } = this.props; + const blockNode = getBlockDOMNode( selectionStart ); + if ( blockNode ) { + const scrollContainer = getScrollContainer( blockNode ); + if ( scrollContainer ) { + scrollContainer.scrollTop = scrollContainer.scrollTop + + blockNode.getBoundingClientRect().top - offset; + } + } + } + + render() { + return null; + } +} + +export default withSelect( ( select ) => { + return { + blockOrder: select( 'core/block-editor' ).getBlockOrder(), + selectionStart: select( 'core/block-editor' ).getBlockSelectionStart(), + }; +} )( PreserveScrollInReorder ); diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js new file mode 100644 index 00000000000000..fd788477a0d6d1 --- /dev/null +++ b/packages/block-editor/src/components/provider/index.js @@ -0,0 +1,130 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { DropZoneProvider, SlotFillProvider } from '@wordpress/components'; +import { withDispatch, withRegistry } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +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 ( + + + { children } + + + ); + } +} + +export default compose( [ + withDispatch( ( dispatch ) => { + const { + updateSettings, + resetBlocks, + } = dispatch( 'core/block-editor' ); + + return { + updateSettings, + resetBlocks, + }; + } ), + withRegistry, +] )( BlockEditorProvider ); diff --git a/packages/block-editor/src/components/rich-text/README.md b/packages/block-editor/src/components/rich-text/README.md new file mode 100644 index 00000000000000..b3565b729cca3a --- /dev/null +++ b/packages/block-editor/src/components/rich-text/README.md @@ -0,0 +1,169 @@ +# `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). + +### `multiline: Boolean | String` + +*Optional.* By default, a line break will be inserted on Enter. If the editable field can contain multiple paragraphs, this property can be set to create new paragraphs on Enter. + +### `onReplace( blocks: Array ): Function` + +*Optional.* Called when the `RichText` instance is empty and it 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. + +### `formattingControls: Array` + +*Optional.* By default, all formatting controls are present. This setting can be used to fine-tune formatting controls. Possible items: `[ 'bold', 'italic', 'strikethrough', 'link' ]`. + +### `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. + +### `keepPlaceholderOnFocus: Boolean` + +*Optional.* By default, the placeholder will hide as soon as the editable field receives focus. With this setting it can be be kept while the field is focussed and empty. + +### `autocompleters: Array` + +*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 ( + setAttributes( { content } ) } + /> + ); + }, + + save( { attributes } ) { + return ; + } +} ); +``` +{% 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 ( + + ); + }, + /* ... */ +} ); +``` +{% end %} diff --git a/packages/editor/src/components/rich-text/aria.js b/packages/block-editor/src/components/rich-text/aria.js similarity index 100% rename from packages/editor/src/components/rich-text/aria.js rename to packages/block-editor/src/components/rich-text/aria.js diff --git a/packages/block-editor/src/components/rich-text/editable.js b/packages/block-editor/src/components/rich-text/editable.js new file mode 100644 index 00000000000000..58c7217e1944c4 --- /dev/null +++ b/packages/block-editor/src/components/rich-text/editable.js @@ -0,0 +1,179 @@ +/** + * External dependencies + */ +import { isEqual } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Component, createElement } from '@wordpress/element'; +import { BACKSPACE, DELETE } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { diffAriaProps } from './aria'; + +/** + * Browser dependencies + */ + +const { userAgent } = window.navigator; + +/** + * Applies a fix that provides `input` events for contenteditable in Internet Explorer. + * + * @param {Element} editorNode The root editor node. + * + * @return {Function} A function to remove the fix (for cleanup). + */ +function applyInternetExplorerInputFix( editorNode ) { + /** + * Dispatches `input` events in response to `textinput` events. + * + * IE provides a `textinput` event that is similar to an `input` event, + * and we use it to manually dispatch an `input` event. + * `textinput` is dispatched for text entry but for not deletions. + * + * @param {Event} textInputEvent An Internet Explorer `textinput` event. + */ + function mapTextInputEvent( textInputEvent ) { + textInputEvent.stopImmediatePropagation(); + + const inputEvent = document.createEvent( 'Event' ); + inputEvent.initEvent( 'input', true, false ); + inputEvent.data = textInputEvent.data; + textInputEvent.target.dispatchEvent( inputEvent ); + } + + /** + * Dispatches `input` events in response to Delete and Backspace keyup. + * + * It would be better dispatch an `input` event after each deleting + * `keydown` because the DOM is updated after each, but it is challenging + * to determine the right time to dispatch `input` since propagation of + * `keydown` can be stopped at any point. + * + * It's easier to listen for `keyup` in the capture phase and dispatch + * `input` before `keyup` propagates further. It's not perfect, but should + * be good enough. + * + * @param {KeyboardEvent} keyUp + * @param {Node} keyUp.target The event target. + * @param {number} keyUp.keyCode The key code. + */ + function mapDeletionKeyUpEvents( { target, keyCode } ) { + const isDeletion = BACKSPACE === keyCode || DELETE === keyCode; + + if ( isDeletion && editorNode.contains( target ) ) { + const inputEvent = document.createEvent( 'Event' ); + inputEvent.initEvent( 'input', true, false ); + inputEvent.data = null; + target.dispatchEvent( inputEvent ); + } + } + + editorNode.addEventListener( 'textinput', mapTextInputEvent ); + document.addEventListener( 'keyup', mapDeletionKeyUpEvents, true ); + return function removeInternetExplorerInputFix() { + editorNode.removeEventListener( 'textinput', mapTextInputEvent ); + document.removeEventListener( 'keyup', mapDeletionKeyUpEvents, true ); + }; +} + +const IS_PLACEHOLDER_VISIBLE_ATTR_NAME = 'data-is-placeholder-visible'; +const CLASS_NAME = 'editor-rich-text__editable block-editor-rich-text__editable'; + +/** + * Whether or not the user agent is Internet Explorer. + * + * @type {boolean} + */ +const IS_IE = userAgent.indexOf( 'Trident' ) >= 0; + +export default class Editable extends Component { + constructor() { + super(); + this.bindEditorNode = this.bindEditorNode.bind( this ); + } + + // We must prevent rerenders because the browser will modify the DOM. React + // will rerender the DOM fine, but we're losing selection and it would be + // more expensive to do so as it would just set the inner HTML through + // `dangerouslySetInnerHTML`. Instead RichText does it's own diffing and + // selection setting. + // + // Because we never update the component, we have to look through props and + // update the attributes on the wrapper nodes here. `componentDidUpdate` + // will never be called. + shouldComponentUpdate( nextProps ) { + this.configureIsPlaceholderVisible( nextProps.isPlaceholderVisible ); + + if ( ! isEqual( this.props.style, nextProps.style ) ) { + this.editorNode.setAttribute( 'style', '' ); + Object.assign( this.editorNode.style, nextProps.style ); + } + + if ( ! isEqual( this.props.className, nextProps.className ) ) { + this.editorNode.className = classnames( nextProps.className, CLASS_NAME ); + } + + const { removedKeys, updatedKeys } = diffAriaProps( this.props, nextProps ); + removedKeys.forEach( ( key ) => + this.editorNode.removeAttribute( key ) ); + updatedKeys.forEach( ( key ) => + this.editorNode.setAttribute( key, nextProps[ key ] ) ); + + return false; + } + + configureIsPlaceholderVisible( isPlaceholderVisible ) { + const isPlaceholderVisibleString = String( !! isPlaceholderVisible ); + if ( this.editorNode.getAttribute( IS_PLACEHOLDER_VISIBLE_ATTR_NAME ) !== isPlaceholderVisibleString ) { + this.editorNode.setAttribute( IS_PLACEHOLDER_VISIBLE_ATTR_NAME, isPlaceholderVisibleString ); + } + } + + bindEditorNode( editorNode ) { + this.editorNode = editorNode; + this.props.setRef( editorNode ); + + if ( IS_IE ) { + if ( editorNode ) { + // Mounting: + this.removeInternetExplorerInputFix = applyInternetExplorerInputFix( editorNode ); + } else { + // Unmounting: + this.removeInternetExplorerInputFix(); + } + } + } + + render() { + const { + tagName = 'div', + style, + record, + valueToEditableHTML, + className, + isPlaceholderVisible, + ...remainingProps + } = this.props; + + delete remainingProps.setRef; + + return createElement( tagName, { + role: 'textbox', + 'aria-multiline': true, + className: classnames( className, CLASS_NAME ), + contentEditable: true, + [ IS_PLACEHOLDER_VISIBLE_ATTR_NAME ]: isPlaceholderVisible, + ref: this.bindEditorNode, + style, + suppressContentEditableWarning: true, + dangerouslySetInnerHTML: { __html: valueToEditableHTML( record ) }, + ...remainingProps, + } ); + } +} diff --git a/packages/block-editor/src/components/rich-text/format-edit.js b/packages/block-editor/src/components/rich-text/format-edit.js new file mode 100644 index 00000000000000..29911206aba839 --- /dev/null +++ b/packages/block-editor/src/components/rich-text/format-edit.js @@ -0,0 +1,49 @@ +/** + * WordPress dependencies + */ +import { withSelect } from '@wordpress/data'; +import { Fragment } from '@wordpress/element'; +import { getActiveFormat, getActiveObject } from '@wordpress/rich-text'; + +const FormatEdit = ( { formatTypes, onChange, value } ) => { + return ( + + { formatTypes.map( ( { name, edit: Edit } ) => { + if ( ! Edit ) { + return null; + } + + const activeFormat = getActiveFormat( value, name ); + const isActive = activeFormat !== undefined; + const activeObject = getActiveObject( value ); + const isObjectActive = activeObject !== undefined; + + return ( + + ); + } ) } + + ); +}; + +export default withSelect( + ( select ) => { + const { getFormatTypes } = select( 'core/rich-text' ); + + return { + formatTypes: getFormatTypes(), + }; + } +)( FormatEdit ); diff --git a/packages/block-editor/src/components/rich-text/format-toolbar/index.js b/packages/block-editor/src/components/rich-text/format-toolbar/index.js new file mode 100644 index 00000000000000..c1b58796268352 --- /dev/null +++ b/packages/block-editor/src/components/rich-text/format-toolbar/index.js @@ -0,0 +1,36 @@ +/** + * External dependencies + */ + +import { orderBy } from 'lodash'; + +/** + * WordPress dependencies + */ + +import { __ } from '@wordpress/i18n'; +import { Toolbar, Slot, DropdownMenu } from '@wordpress/components'; + +const FormatToolbar = ( { controls } ) => { + return ( +
    + + { controls.map( ( format ) => + + ) } + + { ( fills ) => fills.length && + props ), 'title' ) } + /> + } + + +
    + ); +}; + +export default FormatToolbar; diff --git a/packages/block-editor/src/components/rich-text/format-toolbar/index.native.js b/packages/block-editor/src/components/rich-text/format-toolbar/index.native.js new file mode 100644 index 00000000000000..d90860c05f4d67 --- /dev/null +++ b/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 = ( { controls } ) => { + return ( + + { controls.map( ( format ) => + + ) } + + + ); +}; + +export default FormatToolbar; diff --git a/packages/block-editor/src/components/rich-text/format-toolbar/style.scss b/packages/block-editor/src/components/rich-text/format-toolbar/style.scss new file mode 100644 index 00000000000000..dc493c3b0c8451 --- /dev/null +++ b/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/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js new file mode 100644 index 00000000000000..006ec4a697760b --- /dev/null +++ b/packages/block-editor/src/components/rich-text/index.js @@ -0,0 +1,1243 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { + find, + isNil, + isEqual, + omit, + pickBy, + get, + isPlainObject, +} from 'lodash'; +import memize from 'memize'; + +/** + * WordPress dependencies + */ +import { Component, Fragment, RawHTML } from '@wordpress/element'; +import { isHorizontalEdge } from '@wordpress/dom'; +import { createBlobURL } from '@wordpress/blob'; +import { BACKSPACE, DELETE, ENTER, LEFT, RIGHT, SPACE } from '@wordpress/keycodes'; +import { withDispatch, withSelect } from '@wordpress/data'; +import { pasteHandler, children, getBlockTransforms, findTransform } from '@wordpress/blocks'; +import { withInstanceId, withSafeTimeout, compose } from '@wordpress/compose'; +import { isURL } from '@wordpress/url'; +import { + isEmpty, + create, + apply, + applyFormat, + split, + toHTMLString, + getTextContent, + insert, + insertLineBreak, + insertLineSeparator, + isEmptyLine, + unstableToDom, + remove, + removeFormat, + isCollapsed, + LINE_SEPARATOR, + indentListItems, +} from '@wordpress/rich-text'; +import { decodeEntities } from '@wordpress/html-entities'; +import { withFilters, IsolatedEventContainer } from '@wordpress/components'; +import deprecated from '@wordpress/deprecated'; + +/** + * Internal dependencies + */ +import Autocomplete from '../autocomplete'; +import BlockFormatControls from '../block-format-controls'; +import FormatEdit from './format-edit'; +import FormatToolbar from './format-toolbar'; +import Editable from './editable'; +import { pickAriaProps } from './aria'; +import { getPatterns } from './patterns'; +import { withBlockEditContext } from '../block-edit/context'; +import { ListEdit } from './list-edit'; +import { RemoveBrowserShortcuts } from './remove-browser-shortcuts'; + +/** + * Browser dependencies + */ + +const { getSelection, getComputedStyle } = window; + +/** + * All inserting input types that would insert HTML into the DOM. + * + * @see https://www.w3.org/TR/input-events-2/#interface-InputEvent-Attributes + * + * @type {Set} + */ +const INSERTION_INPUT_TYPES_TO_IGNORE = new Set( [ + 'insertParagraph', + 'insertOrderedList', + 'insertUnorderedList', + 'insertHorizontalRule', + 'insertLink', +] ); + +export class RichText extends Component { + constructor( { value, onReplace, multiline } ) { + super( ...arguments ); + + if ( multiline === true || multiline === 'p' || multiline === 'li' ) { + this.multilineTag = multiline === true ? 'p' : multiline; + } + + if ( this.multilineTag === 'li' ) { + this.multilineWrapperTags = [ 'ul', 'ol' ]; + } + + if ( this.props.onSplit ) { + this.onSplit = this.props.onSplit; + + deprecated( 'wp.editor.RichText onSplit prop', { + plugin: 'Gutenberg', + alternative: 'wp.editor.RichText unstableOnSplit prop', + } ); + } else if ( this.props.unstableOnSplit ) { + this.onSplit = this.props.unstableOnSplit; + } + + this.onFocus = this.onFocus.bind( this ); + this.onBlur = this.onBlur.bind( this ); + this.onChange = this.onChange.bind( this ); + this.onDeleteKeyDown = this.onDeleteKeyDown.bind( this ); + this.onKeyDown = this.onKeyDown.bind( this ); + this.onPaste = this.onPaste.bind( this ); + this.onCreateUndoLevel = this.onCreateUndoLevel.bind( this ); + this.setFocusedElement = this.setFocusedElement.bind( this ); + this.onInput = this.onInput.bind( this ); + this.onCompositionEnd = this.onCompositionEnd.bind( this ); + this.onSelectionChange = this.onSelectionChange.bind( this ); + this.getRecord = this.getRecord.bind( this ); + this.createRecord = this.createRecord.bind( this ); + this.applyRecord = this.applyRecord.bind( this ); + this.isEmpty = this.isEmpty.bind( this ); + this.valueToFormat = this.valueToFormat.bind( this ); + this.setRef = this.setRef.bind( this ); + this.valueToEditableHTML = this.valueToEditableHTML.bind( this ); + this.handleHorizontalNavigation = this.handleHorizontalNavigation.bind( this ); + this.onPointerDown = this.onPointerDown.bind( this ); + + this.formatToValue = memize( this.formatToValue.bind( this ), { size: 1 } ); + + this.savedContent = value; + this.patterns = getPatterns( { + onReplace, + valueToFormat: this.valueToFormat, + } ); + this.enterPatterns = getBlockTransforms( 'from' ) + .filter( ( { type } ) => type === 'enter' ); + + this.state = {}; + + this.usedDeprecatedChildrenSource = Array.isArray( value ); + this.lastHistoryValue = value; + } + + componentWillUnmount() { + document.removeEventListener( 'selectionchange', this.onSelectionChange ); + } + + setRef( node ) { + if ( node ) { + if ( process.env.NODE_ENV === 'development' ) { + const computedStyle = getComputedStyle( node ); + + if ( computedStyle.display === 'inline' ) { + // eslint-disable-next-line no-console + console.warn( 'RichText cannot be used with an inline container. Please use a different tagName.' ); + } + } + + this.editableRef = node; + } else { + delete this.editableRef; + } + } + + setFocusedElement() { + if ( this.props.setFocusedElement ) { + this.props.setFocusedElement( this.props.instanceId ); + } + } + + /** + * Get the current record (value and selection) from props and state. + * + * @return {Object} The current record (value and selection). + */ + getRecord() { + const { formats, replacements, text } = this.formatToValue( this.props.value ); + const { start, end, selectedFormat } = this.state; + + return { formats, replacements, text, start, end, selectedFormat }; + } + + createRecord() { + const selection = getSelection(); + const range = selection.rangeCount > 0 ? selection.getRangeAt( 0 ) : null; + + return create( { + element: this.editableRef, + range, + multilineTag: this.multilineTag, + multilineWrapperTags: this.multilineWrapperTags, + prepareEditableTree: this.props.prepareEditableTree, + __unstableIsEditableTree: true, + } ); + } + + applyRecord( record, { domOnly } = {} ) { + apply( { + value: record, + current: this.editableRef, + multilineTag: this.multilineTag, + multilineWrapperTags: this.multilineWrapperTags, + prepareEditableTree: this.props.prepareEditableTree, + __unstableDomOnly: domOnly, + } ); + } + + isEmpty() { + return isEmpty( this.formatToValue( this.props.value ) ); + } + + /** + * Handles a paste event. + * + * Saves the pasted data as plain text in `pastedPlainText`. + * + * @param {PasteEvent} event The paste event. + */ + onPaste( event ) { + const clipboardData = event.clipboardData; + let { items, files } = clipboardData; + + // In Edge these properties can be null instead of undefined, so a more + // rigorous test is required over using default values. + items = isNil( items ) ? [] : items; + files = isNil( files ) ? [] : files; + + let plainText = ''; + let html = ''; + + // IE11 only supports `Text` as an argument for `getData` and will + // otherwise throw an invalid argument error, so we try the standard + // arguments first, then fallback to `Text` if they fail. + try { + plainText = clipboardData.getData( 'text/plain' ); + html = clipboardData.getData( 'text/html' ); + } catch ( error1 ) { + try { + html = clipboardData.getData( 'Text' ); + } catch ( error2 ) { + // Some browsers like UC Browser paste plain text by default and + // don't support clipboardData at all, so allow default + // behaviour. + return; + } + } + + event.preventDefault(); + + // Allows us to ask for this information when we get a report. + window.console.log( 'Received HTML:\n\n', html ); + window.console.log( 'Received plain text:\n\n', plainText ); + + // Only process file if no HTML is present. + // Note: a pasted file may have the URL as plain text. + const item = find( [ ...items, ...files ], ( { type } ) => /^image\/(?:jpe?g|png|gif)$/.test( type ) ); + if ( item && ! html ) { + const file = item.getAsFile ? item.getAsFile() : item; + const content = pasteHandler( { + HTML: ``, + mode: 'BLOCKS', + tagName: this.props.tagName, + } ); + const shouldReplace = this.props.onReplace && this.isEmpty(); + + // Allows us to ask for this information when we get a report. + window.console.log( 'Received item:\n\n', file ); + + if ( shouldReplace ) { + this.props.onReplace( content ); + } else if ( this.onSplit ) { + this.splitContent( content ); + } + + return; + } + + const record = this.getRecord(); + + // There is a selection, check if a URL is pasted. + if ( ! isCollapsed( record ) ) { + const pastedText = ( html || plainText ).replace( /<[^>]+>/g, '' ).trim(); + + // A URL was pasted, turn the selection into a link + if ( isURL( pastedText ) ) { + this.onChange( applyFormat( record, { + type: 'a', + attributes: { + href: decodeEntities( pastedText ), + }, + } ) ); + + // Allows us to ask for this information when we get a report. + window.console.log( 'Created link:\n\n', pastedText ); + + return; + } + } + + const shouldReplace = this.props.onReplace && this.isEmpty(); + + let mode = 'INLINE'; + + if ( shouldReplace ) { + mode = 'BLOCKS'; + } else if ( this.onSplit ) { + mode = 'AUTO'; + } + + const content = pasteHandler( { + HTML: html, + plainText, + mode, + tagName: this.props.tagName, + canUserUseUnfilteredHTML: this.props.canUserUseUnfilteredHTML, + } ); + + if ( typeof content === 'string' ) { + const recordToInsert = create( { html: content } ); + this.onChange( insert( record, recordToInsert ) ); + } else if ( this.onSplit ) { + if ( ! content.length ) { + return; + } + + if ( shouldReplace ) { + this.props.onReplace( content ); + } else { + this.splitContent( content, { paste: true } ); + } + } + } + + /** + * Handles a focus event on the contenteditable field, calling the + * `unstableOnFocus` prop callback if one is defined. The callback does not + * receive any arguments. + * + * This is marked as a private API and the `unstableOnFocus` prop is not + * documented, as the current requirements where it is used are subject to + * future refactoring following `isSelected` handling. + * + * In contrast with `setFocusedElement`, this is only triggered in response + * to focus within the contenteditable field, whereas `setFocusedElement` + * is triggered on focus within any `RichText` descendent element. + * + * @see setFocusedElement + * + * @private + */ + onFocus() { + const { unstableOnFocus } = this.props; + if ( unstableOnFocus ) { + unstableOnFocus(); + } + + document.addEventListener( 'selectionchange', this.onSelectionChange ); + } + + onBlur() { + document.removeEventListener( 'selectionchange', this.onSelectionChange ); + } + + /** + * Handle input on the next selection change event. + * + * @param {SyntheticEvent} event Synthetic input event. + */ + onInput( event ) { + // For Input Method Editor (IME), used in Chinese, Japanese, and Korean + // (CJK), do not trigger a change if characters are being composed. + // Browsers setting `isComposing` to `true` will usually emit a final + // `input` event when the characters are composed. + if ( event && event.nativeEvent.isComposing ) { + // Also don't update any selection. + document.removeEventListener( 'selectionchange', this.onSelectionChange ); + return; + } + + if ( event && event.nativeEvent.inputType ) { + const { inputType } = event.nativeEvent; + + // The browser formatted something or tried to insert HTML. + // Overwrite it. It will be handled later by the format library if + // needed. + if ( + inputType.indexOf( 'format' ) === 0 || + INSERTION_INPUT_TYPES_TO_IGNORE.has( inputType ) + ) { + this.applyRecord( this.getRecord() ); + return; + } + } + + let { selectedFormat } = this.state; + const { formats, replacements, text, start, end } = this.createRecord(); + + if ( this.formatPlaceholder ) { + selectedFormat = this.formatPlaceholder.length; + + if ( selectedFormat > 0 ) { + formats[ this.state.start ] = this.formatPlaceholder; + } else { + delete formats[ this.state.start ]; + } + } else if ( selectedFormat > 0 ) { + const formatsBefore = formats[ start - 1 ] || []; + const formatsAfter = formats[ start ] || []; + + let source = formatsBefore; + + if ( formatsAfter.length > formatsBefore.length ) { + source = formatsAfter; + } + + source = source.slice( 0, selectedFormat ); + + formats[ this.state.start ] = source; + } else { + delete formats[ this.state.start ]; + } + + const change = { formats, replacements, text, start, end, selectedFormat }; + + this.onChange( change, { + withoutHistory: true, + } ); + + const transformed = this.patterns.reduce( + ( accumlator, transform ) => transform( accumlator ), + change + ); + + if ( transformed !== change ) { + this.onCreateUndoLevel(); + this.onChange( { ...transformed, selectedFormat } ); + } + + // Create an undo level when input stops for over a second. + this.props.clearTimeout( this.onInput.timeout ); + this.onInput.timeout = this.props.setTimeout( this.onCreateUndoLevel, 1000 ); + } + + onCompositionEnd() { + // Ensure the value is up-to-date for browsers that don't emit a final + // input event after composition. + this.onInput(); + // Tracking selection changes can be resumed. + document.addEventListener( 'selectionchange', this.onSelectionChange ); + } + + /** + * Handles the `selectionchange` event: sync the selection to local state. + */ + onSelectionChange() { + if ( this.ignoreSelectionChange ) { + delete this.ignoreSelectionChange; + return; + } + + const value = this.createRecord(); + const { start, end, formats } = value; + + if ( start !== this.state.start || end !== this.state.end ) { + const isCaretWithinFormattedText = this.props.isCaretWithinFormattedText; + + if ( ! isCaretWithinFormattedText && formats[ start ] ) { + this.props.onEnterFormattedText(); + } else if ( isCaretWithinFormattedText && ! formats[ start ] ) { + this.props.onExitFormattedText(); + } + + let selectedFormat; + + if ( isCollapsed( value ) ) { + const formatsBefore = formats[ start - 1 ] || []; + const formatsAfter = formats[ start ] || []; + + selectedFormat = Math.min( formatsBefore.length, formatsAfter.length ); + } + + this.setState( { start, end, selectedFormat } ); + this.applyRecord( { ...value, selectedFormat }, { domOnly: true } ); + + delete this.formatPlaceholder; + } + } + + /** + * Calls all registered onChangeEditableValue handlers. + * + * @param {Array} formats The formats of the latest rich-text value. + * @param {string} text The text of the latest rich-text value. + */ + onChangeEditableValue( { formats, text } ) { + get( this.props, [ 'onChangeEditableValue' ], [] ).forEach( ( eventHandler ) => { + eventHandler( formats, text ); + } ); + } + + /** + * Sync the value to global state. The node tree and selection will also be + * updated if differences are found. + * + * @param {Object} record The record to sync and apply. + * @param {Object} $2 Named options. + * @param {boolean} $2.withoutHistory If true, no undo level will be + * created. + */ + onChange( record, { withoutHistory } = {} ) { + this.applyRecord( record ); + + const { start, end, formatPlaceholder, selectedFormat } = record; + + this.formatPlaceholder = formatPlaceholder; + this.onChangeEditableValue( record ); + + this.savedContent = this.valueToFormat( record ); + this.props.onChange( this.savedContent ); + this.setState( { start, end, selectedFormat } ); + + if ( ! withoutHistory ) { + this.onCreateUndoLevel(); + } + } + + onCreateUndoLevel() { + // If the content is the same, no level needs to be created. + if ( this.lastHistoryValue === this.savedContent ) { + return; + } + + this.props.onCreateUndoLevel(); + this.lastHistoryValue = this.savedContent; + } + + /** + * Handles a delete keyDown event to handle merge or removal for collapsed + * selection where caret is at directional edge: forward for a delete key, + * reverse for a backspace key. + * + * @link https://en.wikipedia.org/wiki/Caret_navigation + * + * @param {KeyboardEvent} event Keydown event. + */ + onDeleteKeyDown( event ) { + const { onMerge, onRemove } = this.props; + if ( ! onMerge && ! onRemove ) { + return; + } + + const { keyCode } = event; + const isReverse = keyCode === BACKSPACE; + + // Only process delete if the key press occurs at uncollapsed edge. + if ( ! isCollapsed( this.createRecord() ) ) { + return; + } + + const empty = this.isEmpty(); + + // It is important to consider emptiness because an empty container + // will include a padding BR node _after_ the caret, so in a forward + // deletion the isHorizontalEdge function will incorrectly interpret the + // presence of the BR node as not being at the edge. + const isEdge = ( empty || isHorizontalEdge( this.editableRef, isReverse ) ); + + if ( ! isEdge ) { + return; + } + + 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 && empty && isReverse ) { + onRemove( ! isReverse ); + } + + event.preventDefault(); + } + + /** + * Handles a keydown event. + * + * @param {SyntheticEvent} event A synthetic keyboard event. + */ + onKeyDown( event ) { + const { keyCode, shiftKey, altKey, metaKey, ctrlKey } = event; + + if ( + // Only override left and right keys without modifiers pressed. + ! shiftKey && ! altKey && ! metaKey && ! ctrlKey && + ( keyCode === LEFT || keyCode === RIGHT ) + ) { + this.handleHorizontalNavigation( event ); + } + + // Use the space key in list items (at the start of an item) to indent + // the list item. + if ( keyCode === SPACE && this.multilineTag === 'li' ) { + const value = this.createRecord(); + + if ( isCollapsed( value ) ) { + const { text, start } = value; + const characterBefore = text[ start - 1 ]; + + // The caret must be at the start of a line. + if ( ! characterBefore || characterBefore === LINE_SEPARATOR ) { + this.onChange( indentListItems( value, { type: this.props.tagName } ) ); + event.preventDefault(); + } + } + } + + if ( keyCode === DELETE || keyCode === BACKSPACE ) { + const value = this.createRecord(); + const { replacements, text, start, end } = value; + + // Always handle full content deletion ourselves. + if ( start === 0 && end !== 0 && end === value.text.length ) { + this.onChange( remove( value ) ); + event.preventDefault(); + return; + } + + if ( this.multilineTag ) { + let newValue; + + if ( keyCode === BACKSPACE ) { + const index = start - 1; + + if ( text[ index ] === LINE_SEPARATOR ) { + const collapsed = isCollapsed( value ); + + // If the line separator that is about te be removed + // contains wrappers, remove the wrappers first. + if ( collapsed && replacements[ index ] && replacements[ index ].length ) { + const newReplacements = replacements.slice(); + + newReplacements[ index ] = replacements[ index ].slice( 0, -1 ); + newValue = { + ...value, + replacements: newReplacements, + }; + } else { + newValue = remove( + value, + // Only remove the line if the selection is + // collapsed, otherwise remove the selection. + collapsed ? start - 1 : start, + end + ); + } + } + } else if ( text[ end ] === LINE_SEPARATOR ) { + const collapsed = isCollapsed( value ); + + // If the line separator that is about te be removed + // contains wrappers, remove the wrappers first. + if ( collapsed && replacements[ end ] && replacements[ end ].length ) { + const newReplacements = replacements.slice(); + + newReplacements[ end ] = replacements[ end ].slice( 0, -1 ); + newValue = { + ...value, + replacements: newReplacements, + }; + } else { + newValue = remove( + value, + start, + // Only remove the line if the selection is + // collapsed, otherwise remove the selection. + collapsed ? end + 1 : end, + ); + } + } + + if ( newValue ) { + this.onChange( newValue ); + event.preventDefault(); + } + } + + this.onDeleteKeyDown( event ); + } else if ( keyCode === ENTER ) { + event.preventDefault(); + + const record = this.createRecord(); + + if ( this.props.onReplace ) { + const text = getTextContent( record ); + const transformation = findTransform( this.enterPatterns, ( item ) => { + return item.regExp.test( text ); + } ); + + if ( transformation ) { + this.props.onReplace( [ + transformation.transform( { content: text } ), + ] ); + return; + } + } + + if ( this.multilineTag ) { + if ( event.shiftKey ) { + this.onChange( insertLineBreak( record ) ); + } else if ( this.onSplit && isEmptyLine( record ) ) { + this.onSplit( ...split( record ).map( this.valueToFormat ) ); + } else { + this.onChange( insertLineSeparator( record ) ); + } + } else if ( event.shiftKey || ! this.onSplit ) { + this.onChange( insertLineBreak( record ) ); + } else { + this.splitContent(); + } + } + } + + /** + * Handles horizontal keyboard navigation when no modifiers are pressed. The + * navigation is handled separately to move correctly around format + * boundaries. + * + * @param {SyntheticEvent} event A synthetic keyboard event. + */ + handleHorizontalNavigation( event ) { + const value = this.createRecord(); + const { formats, text, start, end } = value; + const { selectedFormat } = this.state; + const collapsed = isCollapsed( value ); + const isReverse = event.keyCode === LEFT; + + delete this.formatPlaceholder; + + // If the selection is collapsed and at the very start, do nothing if + // navigating backward. + // If the selection is collapsed and at the very end, do nothing if + // navigating forward. + if ( collapsed && selectedFormat === 0 ) { + if ( start === 0 && isReverse ) { + return; + } + + if ( end === text.length && ! isReverse ) { + return; + } + } + + // If the selection is not collapsed, let the browser handle collapsing + // the selection for now. Later we could expand this logic to set + // boundary positions if needed. + if ( ! collapsed ) { + return; + } + + // In all other cases, prevent default behaviour. + event.preventDefault(); + + // Ignore the selection change handler when setting selection, all state + // will be set here. + this.ignoreSelectionChange = true; + + const formatsBefore = formats[ start - 1 ] || []; + const formatsAfter = formats[ start ] || []; + + let newSelectedFormat = selectedFormat; + + // If the amount of formats before the caret and after the caret is + // different, the caret is at a format boundary. + if ( formatsBefore.length < formatsAfter.length ) { + if ( ! isReverse && selectedFormat < formatsAfter.length ) { + newSelectedFormat++; + } + + if ( isReverse && selectedFormat > formatsBefore.length ) { + newSelectedFormat--; + } + } else if ( formatsBefore.length > formatsAfter.length ) { + if ( ! isReverse && selectedFormat > formatsAfter.length ) { + newSelectedFormat--; + } + + if ( isReverse && selectedFormat < formatsBefore.length ) { + newSelectedFormat++; + } + } + + if ( newSelectedFormat !== selectedFormat ) { + this.applyRecord( { ...value, selectedFormat: newSelectedFormat } ); + this.setState( { selectedFormat: newSelectedFormat } ); + return; + } + + const newPos = value.start + ( isReverse ? -1 : 1 ); + + this.setState( { start: newPos, end: newPos } ); + this.applyRecord( { + ...value, + start: newPos, + end: newPos, + selectedFormat: isReverse ? formatsBefore.length : formatsAfter.length, + } ); + } + + /** + * Splits the content at the location of the selection. + * + * Replaces the content of the editor inside this element with the contents + * before the selection. Sends the elements after the selection to the `onSplit` + * handler. + * + * @param {Array} blocks The blocks to add after the split point. + * @param {Object} context The context for splitting. + */ + splitContent( blocks = [], context = {} ) { + if ( ! this.onSplit ) { + return; + } + + const record = this.createRecord(); + let [ before, after ] = split( record ); + + // In case split occurs at the trailing or leading edge of the field, + // assume that the before/after values respectively reflect the current + // value. This also provides an opportunity for the parent component to + // determine whether the before/after value has changed using a trivial + // strict equality operation. + if ( isEmpty( after ) ) { + before = record; + } else if ( isEmpty( before ) ) { + after = record; + } + + // If pasting and the split would result in no content other than the + // pasted blocks, remove the before and after blocks. + if ( context.paste ) { + before = isEmpty( before ) ? null : before; + after = isEmpty( after ) ? null : after; + } + + if ( before ) { + before = this.valueToFormat( before ); + } + + if ( after ) { + after = this.valueToFormat( after ); + } + + this.onSplit( before, after, ...blocks ); + } + + /** + * Select object when they are clicked. The browser will not set any + * selection when clicking e.g. an image. + * + * @param {SyntheticEvent} event Synthetic mousedown or touchstart event. + */ + onPointerDown( event ) { + const { target } = event; + + // If the child element has no text content, it must be an object. + if ( target === this.editableRef || target.textContent ) { + return; + } + + const { parentNode } = target; + const index = Array.from( parentNode.childNodes ).indexOf( target ); + const range = target.ownerDocument.createRange(); + const selection = getSelection(); + + range.setStart( target.parentNode, index ); + range.setEnd( target.parentNode, index + 1 ); + + selection.removeAllRanges(); + selection.addRange( range ); + } + + componentDidUpdate( prevProps ) { + const { tagName, value, isSelected } = this.props; + + if ( + tagName === prevProps.tagName && + value !== prevProps.value && + value !== this.savedContent + ) { + // Handle deprecated `children` and `node` sources. + // The old way of passing a value with the `node` matcher required + // the value to be mapped first, creating a new array each time, so + // a shallow check wouldn't work. We need to check deep equality. + // This is only executed for a deprecated API and will eventually be + // removed. + if ( Array.isArray( value ) && isEqual( value, this.savedContent ) ) { + return; + } + + const record = this.formatToValue( value ); + + if ( isSelected ) { + const prevRecord = this.formatToValue( prevProps.value ); + const length = getTextContent( prevRecord ).length; + record.start = length; + record.end = length; + } + + this.applyRecord( record ); + this.savedContent = value; + } + + // If any format props update, reapply value. + const shouldReapply = Object.keys( this.props ).some( ( name ) => { + if ( name.indexOf( 'format_' ) !== 0 ) { + return false; + } + + // Allow primitives and arrays: + if ( ! isPlainObject( this.props[ name ] ) ) { + return this.props[ name ] !== prevProps[ name ]; + } + + return Object.keys( this.props[ name ] ).some( ( subName ) => { + return this.props[ name ][ subName ] !== prevProps[ name ][ subName ]; + } ); + } ); + + if ( shouldReapply ) { + const record = this.formatToValue( value ); + + // Maintain the previous selection if the instance is currently + // selected. + if ( isSelected ) { + record.start = this.state.start; + record.end = this.state.end; + } + + this.applyRecord( record ); + } + } + + /** + * Get props that are provided by formats to modify RichText. + * + * @return {Object} Props that start with 'format_'. + */ + getFormatProps() { + return pickBy( this.props, ( propValue, name ) => name.startsWith( 'format_' ) ); + } + + /** + * Converts the outside data structure to our internal representation. + * + * @param {*} value The outside value, data type depends on props. + * @return {Object} An internal rich-text value. + */ + formatToValue( value ) { + // Handle deprecated `children` and `node` sources. + if ( Array.isArray( value ) ) { + return create( { + html: children.toHTML( value ), + multilineTag: this.multilineTag, + multilineWrapperTags: this.multilineWrapperTags, + } ); + } + + if ( this.props.format === 'string' ) { + return create( { + html: value, + multilineTag: this.multilineTag, + multilineWrapperTags: this.multilineWrapperTags, + } ); + } + + // Guard for blocks passing `null` in onSplit callbacks. May be removed + // if onSplit is revised to not pass a `null` value. + if ( value === null ) { + return create(); + } + + return value; + } + + valueToEditableHTML( value ) { + return unstableToDom( { + value, + multilineTag: this.multilineTag, + prepareEditableTree: this.props.prepareEditableTree, + } ).body.innerHTML; + } + + /** + * Removes editor only formats from the value. + * + * Editor only formats are applied using `prepareEditableTree`, so we need to + * remove them before converting the internal state + * + * @param {Object} value The internal rich-text value. + * @return {Object} A new rich-text value. + */ + removeEditorOnlyFormats( value ) { + this.props.formatTypes.forEach( ( formatType ) => { + // Remove formats created by prepareEditableTree, because they are editor only. + if ( formatType.__experimentalCreatePrepareEditableTree ) { + value = removeFormat( value, formatType.name, 0, value.text.length ); + } + } ); + + return value; + } + + /** + * Converts the internal value to the external data format. + * + * @param {Object} value The internal rich-text value. + * @return {*} The external data format, data type depends on props. + */ + valueToFormat( value ) { + value = this.removeEditorOnlyFormats( value ); + + // Handle deprecated `children` and `node` sources. + if ( this.usedDeprecatedChildrenSource ) { + return children.fromDOM( unstableToDom( { + value, + multilineTag: this.multilineTag, + isEditableTree: false, + } ).body.childNodes ); + } + + if ( this.props.format === 'string' ) { + return toHTMLString( { + value, + multilineTag: this.multilineTag, + } ); + } + + return value; + } + + render() { + const { + tagName: Tagname = 'div', + style, + wrapperClassName, + className, + inlineToolbar = false, + formattingControls, + placeholder, + keepPlaceholderOnFocus = false, + isSelected, + autocompleters, + onTagNameChange, + } = this.props; + + // Generating a key that includes `tagName` ensures that if the tag + // changes, we replace the relevant element. This is needed because we + // prevent Editable component updates. + const key = Tagname; + const MultilineTag = this.multilineTag; + const ariaProps = pickAriaProps( this.props ); + const isPlaceholderVisible = placeholder && ( ! isSelected || keepPlaceholderOnFocus ) && this.isEmpty(); + const classes = classnames( wrapperClassName, 'editor-rich-text block-editor-rich-text' ); + const record = this.getRecord(); + + return ( +
    + { isSelected && this.multilineTag === 'li' && ( + + ) } + { isSelected && ! inlineToolbar && ( + + + + ) } + { isSelected && inlineToolbar && ( + + + + ) } + + { ( { listBoxId, activeId } ) => ( + + + { isPlaceholderVisible && + + { MultilineTag ? { placeholder } : placeholder } + + } + { isSelected && } + + ) } + + { isSelected && } +
    + ); + } +} + +RichText.defaultProps = { + formattingControls: [ 'bold', 'italic', 'link', 'strikethrough' ], + format: 'string', + value: '', +}; + +const RichTextContainer = compose( [ + withInstanceId, + withBlockEditContext( ( context, ownProps ) => { + // When explicitly set as not selected, do nothing. + if ( ownProps.isSelected === false ) { + return { + clientId: context.clientId, + }; + } + // When explicitly set as selected, use the value stored in the context instead. + if ( ownProps.isSelected === true ) { + return { + isSelected: context.isSelected, + clientId: context.clientId, + }; + } + + // Ensures that only one RichText component can be focused. + return { + isSelected: context.isSelected && context.focusedElement === ownProps.instanceId, + setFocusedElement: context.setFocusedElement, + clientId: context.clientId, + }; + } ), + withSelect( ( select ) => { + // This should probably be moved to the block editor settings. + const { canUserUseUnfilteredHTML } = select( 'core/editor' ); + const { isCaretWithinFormattedText } = select( 'core/block-editor' ); + const { getFormatTypes } = select( 'core/rich-text' ); + + return { + canUserUseUnfilteredHTML: canUserUseUnfilteredHTML(), + isCaretWithinFormattedText: isCaretWithinFormattedText(), + formatTypes: getFormatTypes(), + }; + } ), + withDispatch( ( dispatch ) => { + const { + __unstableMarkLastChangeAsPersistent, + enterFormattedText, + exitFormattedText, + } = dispatch( 'core/block-editor' ); + + return { + onCreateUndoLevel: __unstableMarkLastChangeAsPersistent, + onEnterFormattedText: enterFormattedText, + onExitFormattedText: exitFormattedText, + }; + } ), + withSafeTimeout, + withFilters( 'experimentalRichText' ), +] )( RichText ); + +RichTextContainer.Content = ( { value, tagName: Tag, multiline, ...props } ) => { + let html = value; + let MultilineTag; + + if ( multiline === true || multiline === 'p' || multiline === 'li' ) { + MultilineTag = multiline === true ? 'p' : multiline; + } + + // Handle deprecated `children` and `node` sources. + if ( Array.isArray( value ) ) { + html = children.toHTML( value ); + } + + if ( ! html && MultilineTag ) { + html = `<${ MultilineTag }>`; + } + + const content = { html }; + + if ( Tag ) { + return { content }; + } + + 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: '', +}; + +export default RichTextContainer; +export { RichTextShortcut } from './shortcut'; +export { RichTextToolbarButton } from './toolbar-button'; +export { UnstableRichTextInputEvent } from './input-event'; diff --git a/packages/block-editor/src/components/rich-text/index.native.js b/packages/block-editor/src/components/rich-text/index.native.js new file mode 100644 index 00000000000000..883889c77eb12a --- /dev/null +++ b/packages/block-editor/src/components/rich-text/index.native.js @@ -0,0 +1,588 @@ +/** + * External dependencies + */ +import RCTAztecView from 'react-native-aztec'; +import { View, Platform } from 'react-native'; + +/** + * WordPress dependencies + */ +import { Component, RawHTML } from '@wordpress/element'; +import { withInstanceId, compose } from '@wordpress/compose'; +import { BlockFormatControls } from '@wordpress/block-editor'; +import { withSelect } from '@wordpress/data'; +import { + applyFormat, + getActiveFormat, + isEmpty, + create, + split, + toHTMLString, + insert, + isCollapsed, +} from '@wordpress/rich-text'; +import { decodeEntities } from '@wordpress/html-entities'; +import { BACKSPACE } from '@wordpress/keycodes'; +import { pasteHandler, children } from '@wordpress/blocks'; +import { isURL } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import FormatEdit from './format-edit'; +import FormatToolbar from './format-toolbar'; + +import styles from './style.scss'; + +const isRichTextValueEmpty = ( value ) => { + return ! value || ! value.length; +}; + +const unescapeSpaces = ( text ) => { + return text.replace( / | /gi, ' ' ); +}; + +/** + * Calls {@link pasteHandler} with a fallback to plain text when HTML processing + * results in errors + * + * @param {Object} [options] The options to pass to {@link pasteHandler} + * + * @return {Array|string} A list of blocks or a string, depending on + * `handlerMode`. + */ +const saferPasteHandler = ( options ) => { + try { + return pasteHandler( options ); + } catch ( error ) { + window.console.log( 'Pasting HTML failed:', error ); + window.console.log( 'HTML:', options.HTML ); + window.console.log( 'Falling back to plain text.' ); + // fallback to plain text + return pasteHandler( { ...options, HTML: '' } ); + } +}; + +const gutenbergFormatNamesToAztec = { + 'core/bold': 'bold', + 'core/italic': 'italic', + 'core/strikethrough': 'strikethrough', +}; + +export class RichText extends Component { + constructor() { + super( ...arguments ); + this.isIOS = Platform.OS === 'ios'; + this.onChange = this.onChange.bind( this ); + this.onEnter = this.onEnter.bind( this ); + this.onBackspace = this.onBackspace.bind( this ); + this.onPaste = this.onPaste.bind( this ); + this.onContentSizeChange = this.onContentSizeChange.bind( this ); + this.onFormatChange = this.onFormatChange.bind( this ); + // This prevents a bug in Aztec which triggers onSelectionChange twice on format change + this.onSelectionChange = this.onSelectionChange.bind( this ); + this.valueToFormat = this.valueToFormat.bind( this ); + this.state = { + start: 0, + end: 0, + formatPlaceholder: null, + }; + } + + /** + * Get the current record (value and selection) from props and state. + * + * @return {Object} The current record (value and selection). + */ + getRecord() { + const { formatPlaceholder, start, end } = this.state; + // Since we get the text selection from Aztec we need to be in sync with the HTML `value` + // Removing leading white spaces using `trim()` should make sure this is the case. + const { formats, replacements, text } = this.formatToValue( this.props.value === undefined ? undefined : this.props.value.trimLeft() ); + + return { formats, replacements, formatPlaceholder, text, start, end }; + } + + /* + * Splits the content at the location of the selection. + * + * Replaces the content of the editor inside this element with the contents + * before the selection. Sends the elements after the selection to the `onSplit` + * handler. + * + */ + splitContent( currentRecord, blocks = [], isPasted = false ) { + const { onSplit } = this.props; + + if ( ! onSplit ) { + return; + } + + // TODO : Fix the index position in AztecNative for Android + let [ before, after ] = split( currentRecord ); + + // In case split occurs at the trailing or leading edge of the field, + // assume that the before/after values respectively reflect the current + // value. This also provides an opportunity for the parent component to + // determine whether the before/after value has changed using a trivial + // strict equality operation. + if ( isEmpty( after ) ) { + before = currentRecord; + } else if ( isEmpty( before ) ) { + after = currentRecord; + } + + // If pasting and the split would result in no content other than the + // pasted blocks, remove the before and after blocks. + if ( isPasted ) { + before = isEmpty( before ) ? null : before; + after = isEmpty( after ) ? null : after; + } + + if ( before ) { + before = this.valueToFormat( before ); + } + + if ( after ) { + after = this.valueToFormat( after ); + } + + // The onSplit event can cause a content update event for this block. Such event should + // definitely be processed by our native components, since they have no knowledge of + // how the split works. Setting lastEventCount to undefined forces the native component to + // always update when provided with new content. + this.lastEventCount = undefined; + + onSplit( before, after, ...blocks ); + } + + valueToFormat( value ) { + // remove the outer root tags + return this.removeRootTagsProduceByAztec( toHTMLString( { + value, + multilineTag: this.multilineTag, + } ) ); + } + + getActiveFormatNames( record ) { + const { + formatTypes, + } = this.props; + + return formatTypes.map( ( { name } ) => name ).filter( ( name ) => { + return getActiveFormat( record, name ) !== undefined; + } ).map( ( name ) => gutenbergFormatNamesToAztec[ name ] ).filter( Boolean ); + } + + onFormatChange( record ) { + let newContent; + // valueToFormat might throw when converting the record to a tree structure + // let's ignore the event for now and force a render update so we're still in sync + try { + newContent = this.valueToFormat( record ); + } catch ( error ) { + // eslint-disable-next-line no-console + console.log( error ); + } + this.setState( { + formatPlaceholder: record.formatPlaceholder, + } ); + if ( newContent && newContent !== this.props.value ) { + this.props.onChange( newContent ); + } else { + // make sure the component rerenders without refreshing the text on gutenberg + // (this can trigger other events that might update the active formats on aztec) + this.lastEventCount = 0; + this.forceUpdate(); + } + } + + /* + * Cleans up any root tags produced by aztec. + * TODO: This should be removed on a later version when aztec doesn't return the top tag of the text being edited + */ + + removeRootTagsProduceByAztec( html ) { + let result = this.removeRootTag( this.props.tagName, html ); + // Temporary workaround for https://github.com/WordPress/gutenberg/pull/13763 + if ( this.props.rootTagsToEliminate ) { + this.props.rootTagsToEliminate.forEach( ( element ) => { + result = this.removeRootTag( element, result ); + } ); + } + return result; + } + + removeRootTag( tag, html ) { + const openingTagRegexp = RegExp( '^<' + tag + '>', 'gim' ); + const closingTagRegexp = RegExp( '$', 'gim' ); + return html.replace( openingTagRegexp, '' ).replace( closingTagRegexp, '' ); + } + + /* + * Handles any case where the content of the AztecRN instance has changed + */ + onChange( event ) { + this.lastEventCount = event.nativeEvent.eventCount; + const contentWithoutRootTag = this.removeRootTagsProduceByAztec( unescapeSpaces( event.nativeEvent.text ) ); + this.lastContent = contentWithoutRootTag; + this.props.onChange( this.lastContent ); + } + + /** + * Handles any case where the content of the AztecRN instance has changed in size + */ + + onContentSizeChange( contentSize ) { + const contentHeight = contentSize.height; + this.props.onContentSizeChange( { + aztecHeight: contentHeight, + } ); + } + + // eslint-disable-next-line no-unused-vars + onEnter( event ) { + this.lastEventCount = event.nativeEvent.eventCount; + if ( ! this.props.onSplit ) { + // TODO: insert the \n char instead? + return; + } + + const currentRecord = this.createRecord( { + ...event.nativeEvent, + currentContent: unescapeSpaces( event.nativeEvent.text ), + } ); + + this.splitContent( currentRecord ); + } + + // eslint-disable-next-line no-unused-vars + onBackspace( event ) { + const { onMerge, onRemove } = this.props; + if ( ! onMerge && ! onRemove ) { + return; + } + + const keyCode = BACKSPACE; // TODO : should we differentiate BACKSPACE and DELETE? + const isReverse = keyCode === BACKSPACE; + + const empty = this.isEmpty(); + + 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 && empty && isReverse ) { + onRemove( ! isReverse ); + } + } + + /** + * Handles a paste event from the native Aztec Wrapper. + * + * @param {PasteEvent} event The paste event which wraps `nativeEvent`. + */ + onPaste( event ) { + const isPasted = true; + const { onSplit } = this.props; + + const { pastedText, pastedHtml } = event.nativeEvent; + const currentRecord = this.createRecord( event.nativeEvent ); + + event.preventDefault(); + + // There is a selection, check if a URL is pasted. + if ( ! isCollapsed( currentRecord ) ) { + const trimmedText = ( pastedHtml || pastedText ).replace( /<[^>]+>/g, '' ) + .trim(); + + // A URL was pasted, turn the selection into a link + if ( isURL( trimmedText ) ) { + const linkedRecord = applyFormat( currentRecord, { + type: 'a', + attributes: { + href: decodeEntities( trimmedText ), + }, + } ); + this.lastContent = this.valueToFormat( linkedRecord ); + this.lastEventCount = undefined; + this.props.onChange( this.lastContent ); + + // Allows us to ask for this information when we get a report. + window.console.log( 'Created link:\n\n', trimmedText ); + + return; + } + } + + const shouldReplace = this.props.onReplace && this.isEmpty(); + + let mode = 'INLINE'; + + if ( shouldReplace ) { + mode = 'BLOCKS'; + } else if ( onSplit ) { + mode = 'AUTO'; + } + + const pastedContent = saferPasteHandler( { + HTML: pastedHtml, + plainText: pastedText, + mode, + tagName: this.props.tagName, + canUserUseUnfilteredHTML: this.props.canUserUseUnfilteredHTML, + } ); + + if ( typeof pastedContent === 'string' ) { + const recordToInsert = create( { html: pastedContent } ); + const insertedContent = insert( currentRecord, recordToInsert ); + const newContent = this.valueToFormat( insertedContent ); + this.lastEventCount = undefined; + this.lastContent = newContent; + this.props.onChange( this.lastContent ); + } else if ( onSplit ) { + if ( ! pastedContent.length ) { + return; + } + + if ( shouldReplace ) { + this.props.onReplace( pastedContent ); + } else { + this.splitContent( currentRecord, pastedContent, isPasted ); + } + } + } + + onSelectionChange( start, end, text, event ) { + // `end` can be less than `start` on iOS + // Let's fix that here so `rich-text/slice` can work properly + const realStart = Math.min( start, end ); + const realEnd = Math.max( start, end ); + const noChange = this.state.start === start && this.state.end === end; + const isTyping = this.state.start + 1 === realStart; + const shouldKeepFormats = noChange || isTyping; + // update format placeholder to continue writing in the current format + // or set it to null if user jumped to another part in the text + const formatPlaceholder = shouldKeepFormats && this.state.formatPlaceholder ? { + ...this.state.formatPlaceholder, + index: realStart, + } : null; + this.setState( { + start: realStart, + end: realEnd, + formatPlaceholder, + } ); + this.lastEventCount = event.nativeEvent.eventCount; + // we don't want to refresh aztec as no content can have changed from this event + // let's update lastContent to prevent that in shouldComponentUpdate + this.lastContent = this.removeRootTagsProduceByAztec( unescapeSpaces( text ) ); + this.props.onChange( this.lastContent ); + } + + isEmpty() { + return isEmpty( this.formatToValue( this.props.value ) ); + } + + /** + * Creates a RichText value "record" from native content and selection + * information + * + * @param {string} currentContent The content (usually an HTML string) from + * the native component. + * @param {number} selectionStart The start of the selection. + * @param {number} selectionEnd The end of the selection (same as start if + * cursor instead of selection). + * + * @return {Object} A RichText value with formats and selection. + */ + createRecord( { currentContent, selectionStart, selectionEnd } ) { + // strip outer

    tags + const innerContent = this.removeRootTagsProduceByAztec( currentContent ); + + // create record (with selection) from current contents + const currentRecord = { + start: selectionStart, + end: selectionEnd, + ...create( { + html: innerContent, + range: null, + multilineTag: false, + } ), + }; + + return currentRecord; + } + + formatToValue( value ) { + // Handle deprecated `children` and `node` sources. + if ( Array.isArray( value ) ) { + return create( { + html: children.toHTML( value ), + multilineTag: this.multilineTag, + } ); + } + + if ( this.props.format === 'string' ) { + return create( { + html: value, + multilineTag: this.multilineTag, + } ); + } + + // Guard for blocks passing `null` in onSplit callbacks. May be removed + // if onSplit is revised to not pass a `null` value. + if ( value === null ) { + return create(); + } + + return value; + } + + shouldComponentUpdate( nextProps ) { + if ( nextProps.tagName !== this.props.tagName || nextProps.isSelected !== this.props.isSelected ) { + this.lastEventCount = undefined; + this.lastContent = undefined; + return true; + } + + // TODO: Please re-introduce the check to avoid updating the content right after an `onChange` call. + // It was removed in https://github.com/WordPress/gutenberg/pull/12417 to fix undo/redo problem. + + // If the component is changed React side (undo/redo/merging/splitting/custom text actions) + // we need to make sure the native is updated as well + if ( ( typeof nextProps.value !== 'undefined' ) && + ( typeof this.lastContent !== 'undefined' ) && + nextProps.value !== this.lastContent ) { + this.lastEventCount = undefined; // force a refresh on the native side + } + + return true; + } + + componentDidMount() { + if ( this.props.isSelected ) { + this._editor.focus(); + } + } + + componentWillUnmount() { + if ( this._editor.isFocused() ) { + this._editor.blur(); + } + } + + componentDidUpdate( prevProps ) { + if ( this.props.isSelected && ! prevProps.isSelected ) { + this._editor.focus(); + } else if ( ! this.props.isSelected && prevProps.isSelected && this.isIOS ) { + this._editor.blur(); + } + } + + render() { + const { + tagName, + style, + formattingControls, + isSelected, + } = this.props; + + const record = this.getRecord(); + // Save back to HTML from React tree + const value = this.valueToFormat( record ); + let html = `<${ tagName }>${ value }`; + // We need to check if the value is undefined or empty, and then assign it properly otherwise the placeholder is not visible + if ( value === undefined || value === '' ) { + html = ''; + this.lastEventCount = undefined; // force a refresh on the native side + } + + return ( + + { isSelected && ( + + + + ) } + { + this._editor = ref; + + if ( this.props.setRef ) { + this.props.setRef( ref ); + } + } } + text={ { text: html, eventCount: this.lastEventCount } } + placeholder={ this.props.placeholder } + placeholderTextColor={ this.props.placeholderTextColor || styles[ 'block-editor-rich-text' ].textDecorationColor } + onChange={ this.onChange } + onFocus={ this.props.onFocus } + onBlur={ this.props.onBlur } + onEnter={ this.onEnter } + onBackspace={ this.onBackspace } + onPaste={ this.onPaste } + activeFormats={ this.getActiveFormatNames( record ) } + onContentSizeChange={ this.onContentSizeChange } + onCaretVerticalPositionChange={ this.props.onCaretVerticalPositionChange } + onSelectionChange={ this.onSelectionChange } + isSelected={ isSelected } + blockType={ { tag: tagName } } + color={ 'black' } + maxImagesWidth={ 200 } + style={ style } + fontFamily={ this.props.fontFamily || styles[ 'block-editor-rich-text' ].fontFamily } + fontSize={ this.props.fontSize } + fontWeight={ this.props.fontWeight } + fontStyle={ this.props.fontStyle } + /> + { isSelected && } + + ); + } +} + +RichText.defaultProps = { + formattingControls: [ 'bold', 'italic', 'link', 'strikethrough' ], + format: 'string', +}; + +const RichTextContainer = compose( [ + withInstanceId, + withSelect( ( select ) => { + const { getFormatTypes } = select( 'core/rich-text' ); + + return { + formatTypes: getFormatTypes(), + }; + } ), +] )( RichText ); + +RichTextContainer.Content = ( { value, format, tagName: Tag, ...props } ) => { + let content; + switch ( format ) { + case 'string': + content = { value }; + break; + } + + if ( Tag ) { + return { content }; + } + + return content; +}; + +RichTextContainer.isEmpty = isRichTextValueEmpty; + +RichTextContainer.Content.defaultProps = { + format: 'string', +}; + +export default RichTextContainer; +export { RichTextShortcut } from './shortcut'; +export { RichTextToolbarButton } from './toolbar-button'; +export { UnstableRichTextInputEvent } from './input-event'; diff --git a/packages/block-editor/src/components/rich-text/input-event.js b/packages/block-editor/src/components/rich-text/input-event.js new file mode 100644 index 00000000000000..e77a57cb898e76 --- /dev/null +++ b/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/packages/block-editor/src/components/rich-text/input-event.native.js b/packages/block-editor/src/components/rich-text/input-event.native.js new file mode 100644 index 00000000000000..b8f0fad0a969c1 --- /dev/null +++ b/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/packages/block-editor/src/components/rich-text/list-edit.js b/packages/block-editor/src/components/rich-text/list-edit.js new file mode 100644 index 00000000000000..aee85983258df4 --- /dev/null +++ b/packages/block-editor/src/components/rich-text/list-edit.js @@ -0,0 +1,169 @@ +/** + * WordPress dependencies + */ + +import { Toolbar } from '@wordpress/components'; +import { __, _x } from '@wordpress/i18n'; +import { Fragment } from '@wordpress/element'; +import { + indentListItems, + outdentListItems, + changeListType, +} from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ + +import { RichTextShortcut } from './shortcut'; +import BlockFormatControls from '../block-format-controls'; + +const { TEXT_NODE, ELEMENT_NODE } = window.Node; + +/** + * Gets the selected list node, which is the closest list node to the start of + * the selection. + * + * @return {?Element} The selected list node, or undefined if none is selected. + */ +function getSelectedListNode() { + const selection = window.getSelection(); + + if ( selection.rangeCount === 0 ) { + return; + } + + let { startContainer } = selection.getRangeAt( 0 ); + + if ( startContainer.nodeType === TEXT_NODE ) { + startContainer = startContainer.parentNode; + } + + if ( startContainer.nodeType !== ELEMENT_NODE ) { + return; + } + + const rootNode = startContainer.closest( '*[contenteditable]' ); + + if ( ! rootNode || ! rootNode.contains( startContainer ) ) { + return; + } + + return startContainer.closest( 'ol,ul' ); +} + +/** + * Whether or not the root list is selected. + * + * @return {boolean} True if the root list or nothing is selected, false if an + * inner list is selected. + */ +function isListRootSelected() { + const listNode = getSelectedListNode(); + + // Consider the root list selected if nothing is selected. + return ! listNode || listNode.contentEditable === 'true'; +} + +/** + * Wether or not the selected list has the given tag name. + * + * @param {string} tagName The tag name the list should have. + * @param {string} rootTagName The current root tag name, to compare with in + * case nothing is selected. + * + * @return {boolean} [description] + */ +function isActiveListType( tagName, rootTagName ) { + const listNode = getSelectedListNode(); + + if ( ! listNode ) { + return tagName === rootTagName; + } + + return listNode.nodeName.toLowerCase() === tagName; +} + +export const ListEdit = ( { + onTagNameChange, + tagName, + value, + onChange, +} ) => ( + + { + onChange( outdentListItems( value ) ); + } } + /> + { + onChange( indentListItems( value, { type: tagName } ) ); + } } + /> + { + onChange( indentListItems( value, { type: tagName } ) ); + } } + /> + { + onChange( outdentListItems( value ) ); + } } + /> + + { + onChange( outdentListItems( value ) ); + }, + }, + { + icon: 'editor-indent', + title: __( 'Indent list item' ), + shortcut: _x( 'Space', 'keyboard key' ), + onClick: () => { + onChange( indentListItems( value, { type: tagName } ) ); + }, + }, + ].filter( Boolean ) } + /> + + +); diff --git a/packages/editor/src/components/rich-text/patterns.js b/packages/block-editor/src/components/rich-text/patterns.js similarity index 93% rename from packages/editor/src/components/rich-text/patterns.js rename to packages/block-editor/src/components/rich-text/patterns.js index a9e9a8010149d7..a1d5794cca8289 100644 --- a/packages/editor/src/components/rich-text/patterns.js +++ b/packages/block-editor/src/components/rich-text/patterns.js @@ -10,7 +10,7 @@ import { slice, } from '@wordpress/rich-text'; -export function getPatterns( { onReplace, valueToFormat, onCreateUndoLevel, onChange } ) { +export function getPatterns( { onReplace, valueToFormat } ) { const prefixTransforms = getBlockTransforms( 'from' ) .filter( ( { type } ) => type === 'prefix' ); @@ -40,7 +40,6 @@ export function getPatterns( { onReplace, valueToFormat, onCreateUndoLevel, onCh const content = valueToFormat( slice( record, start, text.length ) ); const block = transformation.transform( content ); - onCreateUndoLevel(); onReplace( [ block ] ); return record; @@ -70,8 +69,6 @@ export function getPatterns( { onReplace, valueToFormat, onCreateUndoLevel, onCh return record; } - onChange( record ); - record = remove( record, startIndex, startIndex + 1 ); record = remove( record, endIndex, endIndex + 1 ); record = applyFormat( record, { type: 'code' }, startIndex, endIndex ); diff --git a/packages/editor/src/components/rich-text/remove-browser-shortcuts.js b/packages/block-editor/src/components/rich-text/remove-browser-shortcuts.js similarity index 100% rename from packages/editor/src/components/rich-text/remove-browser-shortcuts.js rename to packages/block-editor/src/components/rich-text/remove-browser-shortcuts.js diff --git a/packages/editor/src/components/rich-text/shortcut.js b/packages/block-editor/src/components/rich-text/shortcut.js similarity index 100% rename from packages/editor/src/components/rich-text/shortcut.js rename to packages/block-editor/src/components/rich-text/shortcut.js diff --git a/packages/block-editor/src/components/rich-text/shortcut.native.js b/packages/block-editor/src/components/rich-text/shortcut.native.js new file mode 100644 index 00000000000000..61a62170f2768d --- /dev/null +++ b/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/packages/block-editor/src/components/rich-text/style.native.scss b/packages/block-editor/src/components/rich-text/style.native.scss new file mode 100644 index 00000000000000..9436fc9a635285 --- /dev/null +++ b/packages/block-editor/src/components/rich-text/style.native.scss @@ -0,0 +1,7 @@ +@import "variables.scss"; +@import "colors.scss"; + +.block-editor-rich-text { + font-family: $default-regular-font; + text-decoration-color: $gray; +} diff --git a/packages/block-editor/src/components/rich-text/style.scss b/packages/block-editor/src/components/rich-text/style.scss new file mode 100644 index 00000000000000..16fdc282020e0c --- /dev/null +++ b/packages/block-editor/src/components/rich-text/style.scss @@ -0,0 +1,118 @@ + +.block-editor-rich-text { + // This is needed to position the formatting toolbar. + position: relative; +} + +.block-editor-rich-text__editable { + margin: 0; + position: relative; + // In HTML, leading and trailing spaces are not visible, and multiple spaces + // elsewhere are visually reduced to one space. This rule prevents spaces + // from collapsing so all space is visible in the editor and can be removed. + // It also prevents some browsers from inserting non-breaking spaces at the + // end of a line to prevent the space from visually disappearing. Sometimes + // these non breaking spaces can linger in the editor causing unwanted non + // breaking spaces in between words. If also prevent Firefox from inserting + // a trailing `br` node to visualise any trailing space, causing the element + // to be saved. + white-space: pre-wrap; + + > 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; + box-shadow: 0 0 0 1px $light-gray-400; + background: $light-gray-400; + + // Enforce a dark text color so active inline boundaries + // are always readable. + // See https://github.com/WordPress/gutenberg/issues/9508 + color: $dark-gray-900; + } + + // Link inline boundaries get special colors. + a[data-rich-text-format-boundary] { + box-shadow: 0 0 0 1px $blue-medium-100; + background: $blue-medium-100; + color: $blue-medium-900; + } + + // inline boundaries need special treatment because their + // un-selected style is already padded. + code[data-rich-text-format-boundary] { + background: $light-gray-400; + box-shadow: 0 0 0 1px $light-gray-400; + } + } + + &[data-is-placeholder-visible="true"] { + position: absolute; + top: 0; + width: 100%; + margin-top: 0; + + & > p { + margin-top: 0; + } + + // Ensure that if placeholder wraps (mobile/nested contexts) the clickable area is full-height. + height: 100%; + } + + // Placeholder text. + & + .block-editor-rich-text__editable { + pointer-events: none; + + // Use opacity to work in various editor styles. + // We don't specify the color here, because blocks or editor styles might provide their own. + &, + p { + opacity: 0.62; + } + } + + // 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. + &[data-is-placeholder-visible="true"] + figcaption.block-editor-rich-text__editable { + 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; + } +} diff --git a/packages/editor/src/components/rich-text/test/index.js b/packages/block-editor/src/components/rich-text/test/index.js similarity index 100% rename from packages/editor/src/components/rich-text/test/index.js rename to packages/block-editor/src/components/rich-text/test/index.js diff --git a/packages/editor/src/components/rich-text/toolbar-button.js b/packages/block-editor/src/components/rich-text/toolbar-button.js similarity index 100% rename from packages/editor/src/components/rich-text/toolbar-button.js rename to packages/block-editor/src/components/rich-text/toolbar-button.js diff --git a/packages/block-editor/src/components/skip-to-selected-block/index.js b/packages/block-editor/src/components/skip-to-selected-block/index.js new file mode 100644 index 00000000000000..672baa3920fd82 --- /dev/null +++ b/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 && + + ); +}; + +export default withSelect( ( select ) => { + return { + selectedBlockClientId: select( 'core/block-editor' ).getBlockSelectionStart(), + }; +} )( SkipToSelectedBlock ); diff --git a/packages/block-editor/src/components/skip-to-selected-block/style.scss b/packages/block-editor/src/components/skip-to-selected-block/style.scss new file mode 100644 index 00000000000000..a0c707df2167aa --- /dev/null +++ b/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/packages/block-editor/src/components/url-input/README.md b/packages/block-editor/src/components/url-input/README.md new file mode 100644 index 00000000000000..89001d45b3c4e1 --- /dev/null +++ b/packages/block-editor/src/components/url-input/README.md @@ -0,0 +1,204 @@ +# `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 ( + setAttributes( { url, text: (post && post.title) || 'Click here' } ) } + /> + ); + }, + + save( { attributes } ) { + return { attributes.text }; + } +} ); +``` +{% 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 + +## 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 ( + setAttributes( { url, text: (post && post.title) || 'Click here' } ) } + /> + ); + }, + + save( { attributes } ) { + return { attributes.text }; + } +} ); +``` +{% end %} diff --git a/packages/block-editor/src/components/url-input/button.js b/packages/block-editor/src/components/url-input/button.js new file mode 100644 index 00000000000000..1a783a62f36a9c --- /dev/null +++ b/packages/block-editor/src/components/url-input/button.js @@ -0,0 +1,78 @@ +/** + * 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 ( +

    + + { expanded && +
    +
    + + + +
    +
    + } +
    + ); + } +} + +export default URLInputButton; diff --git a/packages/block-editor/src/components/url-input/index.js b/packages/block-editor/src/components/url-input/index.js new file mode 100644 index 00000000000000..66504edec7def2 --- /dev/null +++ b/packages/block-editor/src/components/url-input/index.js @@ -0,0 +1,288 @@ +/** + * 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 { decodeEntities } from '@wordpress/html-entities'; +import { UP, DOWN, ENTER, TAB } from '@wordpress/keycodes'; +import { Spinner, withSpokenMessages, Popover } from '@wordpress/components'; +import { withInstanceId } from '@wordpress/compose'; +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; + +// 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 = { + posts: [], + 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, + } ); + + setTimeout( () => { + this.scrollingIntoView = false; + }, 100 ); + } + } + + componentWillUnmount() { + delete this.suggestionsRequest; + } + + bindSuggestionNode( index ) { + return ( ref ) => { + this.suggestionNodes[ index ] = ref; + }; + } + + updateSuggestions( value ) { + // 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 = apiFetch( { + path: addQueryArgs( '/wp/v2/search', { + search: value, + per_page: 20, + type: 'post', + } ), + } ); + + request.then( ( posts ) => { + // 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( { + posts, + loading: false, + } ); + + if ( !! posts.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.', + posts.length + ), posts.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, posts, 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 || ! posts.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 post = this.state.posts[ this.state.selectedSuggestion ]; + + switch ( event.keyCode ) { + case UP: { + event.stopPropagation(); + event.preventDefault(); + const previousIndex = ! selectedSuggestion ? posts.length - 1 : selectedSuggestion - 1; + this.setState( { + selectedSuggestion: previousIndex, + } ); + break; + } + case DOWN: { + event.stopPropagation(); + event.preventDefault(); + const nextIndex = selectedSuggestion === null || ( selectedSuggestion === posts.length - 1 ) ? 0 : selectedSuggestion + 1; + this.setState( { + selectedSuggestion: nextIndex, + } ); + break; + } + case TAB: { + if ( this.state.selectedSuggestion !== null ) { + this.selectLink( post ); + // 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( post ); + } + break; + } + } + } + + selectLink( post ) { + this.props.onChange( post.url, post ); + this.setState( { + selectedSuggestion: null, + showSuggestions: false, + } ); + } + + handleOnClick( post ) { + this.selectLink( post ); + // Move focus to the input field when a link suggestion is clicked. + this.inputRef.current.focus(); + } + + render() { + const { value = '', autoFocus = true, instanceId, className } = this.props; + const { showSuggestions, posts, selectedSuggestion, loading } = this.state; + /* eslint-disable jsx-a11y/no-autofocus */ + return ( +
    + + + { ( loading ) && } + + { showSuggestions && !! posts.length && + +
    + { posts.map( ( post, index ) => ( + + ) ) } +
    +
    + } +
    + ); + /* eslint-enable jsx-a11y/no-autofocus */ + } +} + +export default withSpokenMessages( withInstanceId( URLInput ) ); diff --git a/packages/block-editor/src/components/url-input/index.native.js b/packages/block-editor/src/components/url-input/index.native.js new file mode 100644 index 00000000000000..a76252cadff39a --- /dev/null +++ b/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 ( + + ); + /* eslint-enable jsx-a11y/no-autofocus */ + } +} + +export default withInstanceId( URLInput ); diff --git a/packages/block-editor/src/components/url-input/style.scss b/packages/block-editor/src/components/url-input/style.scss new file mode 100644 index 00000000000000..de6d71b9facea3 --- /dev/null +++ b/packages/block-editor/src/components/url-input/style.scss @@ -0,0 +1,124 @@ +// 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; + } + } + + .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; + 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/packages/block-editor/src/components/url-input/test/button.js b/packages/block-editor/src/components/url-input/test/button.js new file mode 100644 index 00000000000000..f9bc3aee58e8ff --- /dev/null +++ b/packages/block-editor/src/components/url-input/test/button.js @@ -0,0 +1,80 @@ +/** + * 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'; + +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( ); + 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( ); + expect( wrapper.find( 'ForwardRef(IconButton)' ).hasClass( 'is-active' ) ).toBe( false ); + } ); + it( 'should have is-active class name if url prop defined', () => { + const wrapper = shallow( ); + expect( wrapper.find( 'ForwardRef(IconButton)' ).hasClass( 'is-active' ) ).toBe( true ); + } ); + it( 'should have hidden form by default', () => { + const wrapper = shallow( ); + 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( ); + 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( ); + 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( ); + 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( ); + 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( ); + 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 react/no-find-dom-node */ + ReactDOM.unmountComponentAtNode( ReactDOM.findDOMNode( wrapper ).parentNode ); + /* eslint-enable react/no-find-dom-node */ + } ); +} ); diff --git a/packages/block-editor/src/components/url-popover/README.md b/packages/block-editor/src/components/url-popover/README.md new file mode 100644 index 00000000000000..4705ecadfe4373 --- /dev/null +++ b/packages/block-editor/src/components/url-popover/README.md @@ -0,0 +1,122 @@ +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 { Fragment } from '@wordpress/elements'; +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 ( + + + { isVisible && ( + ( + + ) } + > +
    + + + +
    + ) } +
    + ); + } +} +``` + +## Props + +The component accepts the following props. + +### 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" + +### onClose + +Callback that triggers when the user indicates the popover should close (e.g. they've used the escape key or clicked +outside of the popover.) + +- Type: `Function` +- Required: No + +### 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 diff --git a/packages/block-editor/src/components/url-popover/index.js b/packages/block-editor/src/components/url-popover/index.js new file mode 100644 index 00000000000000..d173d110c3977f --- /dev/null +++ b/packages/block-editor/src/components/url-popover/index.js @@ -0,0 +1,74 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { + Popover, + IconButton, +} from '@wordpress/components'; + +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 { + children, + renderSettings, + onClose, + onClickOutside, + position = 'bottom center', + focusOnMount = 'firstElement', + } = this.props; + + const { + isSettingsExpanded, + } = this.state; + + const showSettings = !! renderSettings && isSettingsExpanded; + + return ( + +
    + { children } + { !! renderSettings && ( + + ) } +
    + { showSettings && ( +
    + { renderSettings() } +
    + ) } +
    + ); + } +} + +export default URLPopover; diff --git a/packages/block-editor/src/components/url-popover/style.scss b/packages/block-editor/src/components/url-popover/style.scss new file mode 100644 index 00000000000000..0b98f11afcd311 --- /dev/null +++ b/packages/block-editor/src/components/url-popover/style.scss @@ -0,0 +1,59 @@ +.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 { + padding: $panel-padding; + border-top: $border-width solid $light-gray-500; + + .components-base-control:last-child .components-base-control__field { + margin-bottom: 0; + } +} diff --git a/packages/block-editor/src/components/url-popover/test/__snapshots__/index.js.snap b/packages/block-editor/src/components/url-popover/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000000..9613a40af346ac --- /dev/null +++ b/packages/block-editor/src/components/url-popover/test/__snapshots__/index.js.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`URLPopover matches the snapshot in its default state 1`] = ` + +
    +
    + Editor +
    + +
    +
    +`; + +exports[`URLPopover matches the snapshot when the settings are toggled open 1`] = ` + +
    +
    + Editor +
    + +
    +
    +
    + Settings +
    +
    +
    +`; + +exports[`URLPopover matches the snapshot when there are no settings 1`] = ` + +
    +
    + Editor +
    +
    +
    +`; diff --git a/packages/block-editor/src/components/url-popover/test/index.js b/packages/block-editor/src/components/url-popover/test/index.js new file mode 100644 index 00000000000000..e541343151034f --- /dev/null +++ b/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( + ( +
    Settings
    + ) } + > +
    Editor
    +
    + ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'matches the snapshot when the settings are toggled open', () => { + const wrapper = shallow( + ( +
    Settings
    + ) } + > +
    Editor
    +
    + ); + + 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( + +
    Editor
    +
    + ); + + expect( wrapper ).toMatchSnapshot(); + } ); +} ); diff --git a/packages/block-editor/src/components/warning/index.js b/packages/block-editor/src/components/warning/index.js new file mode 100644 index 00000000000000..38e7dc18a64508 --- /dev/null +++ b/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 ( +
    +
    +

    { children }

    + + { Children.count( actions ) > 0 && ( +
    + { Children.map( actions, ( action, i ) => ( + + { action } + + ) ) } +
    + ) } +
    + + { secondaryActions && ( + ( + + ) } + renderContent={ () => ( + + { secondaryActions.map( ( item, pos ) => + + { item.title } + + ) } + + ) } + /> + ) } +
    + ); +} + +export default Warning; diff --git a/packages/block-editor/src/components/warning/style.scss b/packages/block-editor/src/components/warning/style.scss new file mode 100644 index 00000000000000..6af2f5e9a77043 --- /dev/null +++ b/packages/block-editor/src/components/warning/style.scss @@ -0,0 +1,70 @@ +.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: 20px; + + // 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; + 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; + } + + .block-editor-warning__contents { + display: flex; + flex-direction: row; + justify-content: space-between; + flex-wrap: wrap; + align-items: center; + width: 100%; + } + + .block-editor-warning__actions { + display: flex; + } + + .block-editor-warning__action { + margin: 0 6px 0 0; + } +} + +.block-editor-warning__secondary { + margin: 3px 0 0 -4px; + + // 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/packages/block-editor/src/components/warning/test/__snapshots__/index.js.snap b/packages/block-editor/src/components/warning/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000000..de9e8358fdb80b --- /dev/null +++ b/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`] = ` +
    +
    +

    + error +

    +
    +
    +`; diff --git a/packages/block-editor/src/components/warning/test/index.js b/packages/block-editor/src/components/warning/test/index.js new file mode 100644 index 00000000000000..f1a10c4e6fbc3f --- /dev/null +++ b/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( error ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'should have valid class', () => { + const wrapper = shallow( ); + + 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( }>Message ); + + 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( Message ); + + const actions = wrapper.find( '.block-editor-warning__secondary' ); + + expect( actions ).toHaveLength( 1 ); + } ); +} ); diff --git a/packages/block-editor/src/components/writing-flow/index.js b/packages/block-editor/src/components/writing-flow/index.js new file mode 100644 index 00000000000000..d03b11d0a84f7a --- /dev/null +++ b/packages/block-editor/src/components/writing-flow/index.js @@ -0,0 +1,400 @@ +/** + * External dependencies + */ +import { overEvery, find, findLast, reverse, first, last } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { + computeCaretRect, + focus, + isHorizontalEdge, + isTextField, + isVerticalEdge, + placeCaretAtHorizontalEdge, + placeCaretAtVerticalEdge, + isEntirelySelected, +} from '@wordpress/dom'; +import { UP, DOWN, LEFT, RIGHT, 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 } = 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.clearVerticalRect = this.clearVerticalRect.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; + } + + bindContainer( ref ) { + this.container = ref; + } + + clearVerticalRect() { + this.verticalRect = null; + } + + /** + * 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, + selectionBeforeEndClientId, + selectionAfterEndClientId, + } = this.props; + + const { keyCode, target } = event; + const isUp = keyCode === UP; + const isDown = keyCode === DOWN; + const isLeft = keyCode === LEFT; + const isRight = keyCode === RIGHT; + 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; + + // 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; + } + + if ( ! isVertical ) { + this.verticalRect = null; + } else if ( ! this.verticalRect ) { + this.verticalRect = computeCaretRect( target ); + } + + 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, isReverse ) ) { + const closestTabbable = this.getClosestTabbable( target, isReverse ); + placeCaretAtHorizontalEdge( closestTabbable, isReverse ); + event.preventDefault(); + } + } + + /** + * Sets focus to the end of the last tabbable text field, if one exists. + */ + focusLastTextField() { + 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 ( +
    +
    + { children } +
    +
    +
    + ); + /* eslint-disable jsx-a11y/no-static-element-interactions */ + } +} + +export default compose( [ + withSelect( ( select ) => { + const { + getSelectedBlockClientId, + getMultiSelectedBlocksStartClientId, + getMultiSelectedBlocksEndClientId, + getPreviousBlockClientId, + getNextBlockClientId, + getFirstMultiSelectedBlockClientId, + getLastMultiSelectedBlockClientId, + hasMultiSelection, + getBlockOrder, + } = 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(), + }; + } ), + withDispatch( ( dispatch ) => { + const { multiSelect, selectBlock } = dispatch( 'core/block-editor' ); + return { + onMultiSelect: multiSelect, + onSelectBlock: selectBlock, + }; + } ), +] )( WritingFlow ); diff --git a/packages/block-editor/src/components/writing-flow/style.scss b/packages/block-editor/src/components/writing-flow/style.scss new file mode 100644 index 00000000000000..e1ff5e860ad149 --- /dev/null +++ b/packages/block-editor/src/components/writing-flow/style.scss @@ -0,0 +1,10 @@ +.block-editor-writing-flow { + height: 100%; + display: flex; + flex-direction: column; +} + +.block-editor-writing-flow__click-redirect { + flex-basis: 100%; + cursor: text; +} diff --git a/packages/editor/src/components/writing-flow/test/index.js b/packages/block-editor/src/components/writing-flow/test/index.js similarity index 100% rename from packages/editor/src/components/writing-flow/test/index.js rename to packages/block-editor/src/components/writing-flow/test/index.js diff --git a/packages/editor/src/hooks/align.js b/packages/block-editor/src/hooks/align.js similarity index 96% rename from packages/editor/src/hooks/align.js rename to packages/block-editor/src/hooks/align.js index 46adcc0ba3ede8..ca0a019d5cc9e6 100644 --- a/packages/editor/src/hooks/align.js +++ b/packages/block-editor/src/hooks/align.js @@ -168,9 +168,9 @@ export const withDataAlign = createHigherOrderComponent( compose( [ withSelect( ( select ) => { - const { getEditorSettings } = select( 'core/editor' ); + const { getSettings } = select( 'core/block-editor' ); return { - hasWideEnabled: !! getEditorSettings().alignWide, + hasWideEnabled: !! getSettings().alignWide, }; } ), @@ -206,7 +206,7 @@ export function addAssignedAlign( props, blockType, attributes ) { } addFilter( 'blocks.registerBlockType', 'core/align/addAttribute', addAttribute ); -addFilter( 'editor.__experimentalBlockListBlock', 'core/editor/align/with-data-align', withDataAlign ); +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/packages/editor/src/hooks/anchor.js b/packages/block-editor/src/hooks/anchor.js similarity index 100% rename from packages/editor/src/hooks/anchor.js rename to packages/block-editor/src/hooks/anchor.js diff --git a/packages/editor/src/hooks/custom-class-name.js b/packages/block-editor/src/hooks/custom-class-name.js similarity index 98% rename from packages/editor/src/hooks/custom-class-name.js rename to packages/block-editor/src/hooks/custom-class-name.js index 9779dfd1a2bb5a..3d4af472276701 100644 --- a/packages/editor/src/hooks/custom-class-name.js +++ b/packages/block-editor/src/hooks/custom-class-name.js @@ -65,7 +65,7 @@ export const withInspectorControl = createHigherOrderComponent( ( BlockEdit ) => value={ props.attributes.className || '' } onChange={ ( nextValue ) => { props.setAttributes( { - className: nextValue, + className: nextValue !== '' ? nextValue : undefined, } ); } } /> diff --git a/packages/editor/src/hooks/custom-class-name.native.js b/packages/block-editor/src/hooks/custom-class-name.native.js similarity index 100% rename from packages/editor/src/hooks/custom-class-name.native.js rename to packages/block-editor/src/hooks/custom-class-name.native.js diff --git a/packages/editor/src/hooks/generated-class-name.js b/packages/block-editor/src/hooks/generated-class-name.js similarity index 100% rename from packages/editor/src/hooks/generated-class-name.js rename to packages/block-editor/src/hooks/generated-class-name.js diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js new file mode 100644 index 00000000000000..7bd9f390390c4d --- /dev/null +++ b/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/packages/block-editor/src/hooks/index.native.js b/packages/block-editor/src/hooks/index.native.js new file mode 100644 index 00000000000000..d85f61d596d93b --- /dev/null +++ b/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/packages/editor/src/hooks/test/align.js b/packages/block-editor/src/hooks/test/align.js similarity index 100% rename from packages/editor/src/hooks/test/align.js rename to packages/block-editor/src/hooks/test/align.js diff --git a/packages/editor/src/hooks/test/anchor.js b/packages/block-editor/src/hooks/test/anchor.js similarity index 100% rename from packages/editor/src/hooks/test/anchor.js rename to packages/block-editor/src/hooks/test/anchor.js diff --git a/packages/editor/src/hooks/test/custom-class-name.js b/packages/block-editor/src/hooks/test/custom-class-name.js similarity index 100% rename from packages/editor/src/hooks/test/custom-class-name.js rename to packages/block-editor/src/hooks/test/custom-class-name.js diff --git a/packages/editor/src/hooks/test/generated-class-name.js b/packages/block-editor/src/hooks/test/generated-class-name.js similarity index 98% rename from packages/editor/src/hooks/test/generated-class-name.js rename to packages/block-editor/src/hooks/test/generated-class-name.js index 509b06e61d06be..397ec9ab333cef 100644 --- a/packages/editor/src/hooks/test/generated-class-name.js +++ b/packages/block-editor/src/hooks/test/generated-class-name.js @@ -4,7 +4,7 @@ import { noop } from 'lodash'; /** - * External dependencies + * WordPress dependencies */ import { applyFilters } from '@wordpress/hooks'; diff --git a/packages/block-editor/src/index.js b/packages/block-editor/src/index.js new file mode 100644 index 00000000000000..fe526a2352a428 --- /dev/null +++ b/packages/block-editor/src/index.js @@ -0,0 +1,17 @@ +/** + * WordPress dependencies + */ +import '@wordpress/blocks'; +import '@wordpress/core-data'; +import '@wordpress/rich-text'; +import '@wordpress/viewport'; + +/** + * Internal dependencies + */ +import './store'; +import './hooks'; + +export * from './components'; + +export { SETTINGS_DEFAULTS } from './store/defaults'; diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js new file mode 100644 index 00000000000000..a2a1bc6e6e628c --- /dev/null +++ b/packages/block-editor/src/store/actions.js @@ -0,0 +1,555 @@ +/** + * External dependencies + */ +import { castArray } from 'lodash'; + +/** + * WordPress dependencies + */ +import { getDefaultBlockName, createBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { select } from './controls'; + +/** + * 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 + ); + + 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 + ); + + 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, + }; +} + +/** + * 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). + * + * @return {Object} Action object. + */ +export function replaceBlocks( clientIds, blocks ) { + return { + type: 'REPLACE_BLOCKS', + clientIds: castArray( clientIds ), + blocks: castArray( blocks ), + time: Date.now(), + }; +} + +/** + * 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. + * + * @return {Object} Action object. + */ +export function moveBlockToPosition( clientId, fromRootClientId, toRootClientId, index ) { + return { + type: 'MOVE_BLOCK_TO_POSITION', + fromRootClientId, + toRootClientId, + clientId, + index, + }; +} + +/** + * 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 ) { + return { + type: 'INSERT_BLOCKS', + blocks: castArray( blocks ), + 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, + }; + + 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 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 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 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 ) { + const block = createBlock( getDefaultBlockName(), 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' }; +} + diff --git a/packages/editor/src/store/array.js b/packages/block-editor/src/store/array.js similarity index 100% rename from packages/editor/src/store/array.js rename to packages/block-editor/src/store/array.js diff --git a/packages/block-editor/src/store/controls.js b/packages/block-editor/src/store/controls.js new file mode 100644 index 00000000000000..5012ab244c21c8 --- /dev/null +++ b/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/packages/block-editor/src/store/defaults.js b/packages/block-editor/src/store/defaults.js new file mode 100644 index 00000000000000..14c114216f12a9 --- /dev/null +++ b/packages/block-editor/src/store/defaults.js @@ -0,0 +1,140 @@ +/** + * 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 + */ +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: __( '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, +}; + diff --git a/packages/block-editor/src/store/effects.js b/packages/block-editor/src/store/effects.js new file mode 100644 index 00000000000000..ca46e1afb8f4a6 --- /dev/null +++ b/packages/block-editor/src/store/effects.js @@ -0,0 +1,147 @@ +/** + * WordPress dependencies + */ +import { speak } from '@wordpress/a11y'; +import { + getBlockType, + doBlocksMatchTemplate, + switchToBlockType, + synchronizeBlocksWithTemplate, +} from '@wordpress/blocks'; +import { _n, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { + replaceBlocks, + selectBlock, + setTemplateValidity, + insertDefaultBlock, + resetBlocks, +} from './actions'; +import { + getBlock, + getBlocks, + getSelectedBlockCount, + getBlockCount, + getTemplateLock, + getTemplate, + isValidTemplate, +} 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 ); + } +} + +/** + * Effect handler which will return a default block insertion action if there + * are no other blocks at the root of the editor. This is expected to be used + * in actions which may result in no blocks remaining in the editor (removal, + * replacement, etc). + * + * @param {Object} action Action which had initiated the effect handler. + * @param {Object} store Store instance. + * + * @return {?Object} Default block insert action, if no other blocks exist. + */ +export function ensureDefaultBlock( action, store ) { + if ( ! getBlockCount( store.getState() ) ) { + return insertDefaultBlock(); + } +} + +export default { + MERGE_BLOCKS( action, store ) { + const { dispatch } = store; + const state = store.getState(); + const [ firstBlockClientId, secondBlockClientId ] = action.blocks; + const blockA = getBlock( state, firstBlockClientId ); + const blockType = getBlockType( blockA.name ); + + // Only focus the previous block if it's not mergeable + if ( ! blockType.merge ) { + dispatch( selectBlock( blockA.clientId ) ); + return; + } + + // We can only merge blocks with similar types + // thus, we transform the block to merge first + const blockB = getBlock( state, secondBlockClientId ); + const blocksWithTheSameType = blockA.name === blockB.name ? + [ blockB ] : + switchToBlockType( blockB, 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 = blockType.merge( + blockA.attributes, + blocksWithTheSameType[ 0 ].attributes + ); + + dispatch( selectBlock( blockA.clientId, -1 ) ); + dispatch( replaceBlocks( + [ blockA.clientId, blockB.clientId ], + [ + { + ...blockA, + attributes: { + ...blockA.attributes, + ...updatedAttributes, + }, + }, + ...blocksWithTheSameType.slice( 1 ), + ] + ) ); + }, + RESET_BLOCKS: [ + validateBlocksToTemplate, + ], + REPLACE_BLOCKS: [ + ensureDefaultBlock, + ], + 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/packages/block-editor/src/store/index.js b/packages/block-editor/src/store/index.js new file mode 100644 index 00000000000000..0119e63d7a3d16 --- /dev/null +++ b/packages/block-editor/src/store/index.js @@ -0,0 +1,29 @@ +/** + * 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'; + +const store = registerStore( MODULE_KEY, { + reducer, + selectors, + actions, + controls, + persist: [ 'preferences' ], +} ); +applyMiddlewares( store ); + +export default store; diff --git a/packages/block-editor/src/store/middlewares.js b/packages/block-editor/src/store/middlewares.js new file mode 100644 index 00000000000000..6381132bb81e08 --- /dev/null +++ b/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/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js new file mode 100644 index 00000000000000..007c5f4a8f6d4b --- /dev/null +++ b/packages/block-editor/src/store/reducer.js @@ -0,0 +1,933 @@ +/** + * External dependencies + */ +import { + flow, + reduce, + first, + last, + omit, + without, + mapValues, + keys, + isEqual, + isEmpty, + get, +} 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; +} + +/** + * 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 ) { + 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 ) + ); +} + +/** + * 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; + + /** + * Set of action types for which a blocks state change should be considered + * non-persistent. + * + * @type {Set} + */ + const IGNORED_ACTION_TYPES = new Set( [ + 'RECEIVE_BLOCKS', + ] ); + + 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 ) { + return { + ...nextState, + isPersistentChange: get( state, [ 'isPersistentChange' ], true ), + }; + } + + // Some state changes should not be considered persistent, namely those + // which are not a direct result of user interaction. + const isIgnoredActionType = IGNORED_ACTION_TYPES.has( action.type ); + if ( isIgnoredActionType ) { + return { + ...nextState, + isPersistentChange: false, + }; + } + + 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 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 ) => { + if ( state && action.type === 'REMOVE_BLOCKS' ) { + const clientIds = [ ...action.clientIds ]; + + // For each removed client ID, include its inner blocks to remove, + // recursing into those so long as inner blocks exist. + for ( let i = 0; i < clientIds.length; i++ ) { + clientIds.push( ...state.order[ clientIds[ i ] ] ); + } + + action = { ...action, clientIds }; + } + + 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 ), + }, + }; + } + + return reducer( state, action ); +}; + +/** + * 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. + * + * @returns {Object} Updated state. + */ +export const blocks = flow( + combineReducers, + withInnerBlocksRemoveCascade, + withBlockReset, + withSaveReusableBlock, + withPersistentBlockChange, +)( { + byClientId( state = {}, action ) { + switch ( action.type ) { + case 'RESET_BLOCKS': + return getFlattenedBlocksWithoutAttributes( action.blocks ); + + case 'RECEIVE_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 'INSERT_BLOCKS': + return { + ...state, + ...getFlattenedBlocksWithoutAttributes( action.blocks ), + }; + + case 'REPLACE_BLOCKS': + if ( ! action.blocks ) { + return state; + } + + return { + ...omit( state, action.clientIds ), + ...getFlattenedBlocksWithoutAttributes( action.blocks ), + }; + + case 'REMOVE_BLOCKS': + return omit( state, action.clientIds ); + } + + return state; + }, + + attributes( state = {}, action ) { + switch ( action.type ) { + case 'RESET_BLOCKS': + return getFlattenedBlockAttributes( action.blocks ); + + case 'RECEIVE_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 'INSERT_BLOCKS': + return { + ...state, + ...getFlattenedBlockAttributes( action.blocks ), + }; + + case 'REPLACE_BLOCKS': + if ( ! action.blocks ) { + return state; + } + + return { + ...omit( state, action.clientIds ), + ...getFlattenedBlockAttributes( action.blocks ), + }; + + case 'REMOVE_BLOCKS': + return omit( state, action.clientIds ); + } + + 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': { + const { clientIds } = action; + if ( ! action.blocks ) { + return state; + } + + const mappedBlocks = mapBlockOrder( action.blocks ); + + return flow( [ + ( nextState ) => omit( nextState, clientIds ), + ( 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': + return flow( [ + // Remove inner block ordering for removed blocks + ( nextState ) => omit( nextState, action.clientIds ), + + // Remove deleted blocks from other blocks' orderings + ( nextState ) => mapValues( nextState, ( subState ) => ( + without( subState, ...action.clientIds ) + ) ), + ] )( state ); + } + + 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; +} + +/** + * 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 = { + start: null, + end: null, + isMultiSelecting: false, + isEnabled: true, + initialPosition: null, +}, action ) { + switch ( action.type ) { + case 'CLEAR_SELECTED_BLOCK': + if ( state.start === null && state.end === null && ! state.isMultiSelecting ) { + return state; + } + + return { + ...state, + start: null, + end: null, + 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, + start: action.start, + end: action.end, + initialPosition: null, + }; + case 'SELECT_BLOCK': + if ( action.clientId === state.start && action.clientId === state.end ) { + return state; + } + return { + ...state, + start: action.clientId, + end: action.clientId, + initialPosition: action.initialPosition, + }; + case 'INSERT_BLOCKS': { + if ( action.updateSelection ) { + return { + ...state, + start: action.blocks[ 0 ].clientId, + end: action.blocks[ 0 ].clientId, + initialPosition: null, + isMultiSelecting: false, + }; + } + return state; + } + case 'REMOVE_BLOCKS': + if ( ! action.clientIds || ! action.clientIds.length || action.clientIds.indexOf( state.start ) === -1 ) { + return state; + } + return { + ...state, + start: null, + end: null, + initialPosition: null, + isMultiSelecting: false, + }; + case 'REPLACE_BLOCKS': + if ( action.clientIds.indexOf( state.start ) === -1 ) { + return state; + } + + // If there are replacement blocks, assign last block as the next + // selected block, otherwise set to null. + const lastBlock = last( action.blocks ); + const nextSelectedBlockClientId = lastBlock ? lastBlock.clientId : null; + + if ( nextSelectedBlockClientId === state.start && nextSelectedBlockClientId === state.end ) { + return state; + } + + return { + ...state, + start: nextSelectedBlockClientId, + end: nextSelectedBlockClientId, + initialPosition: null, + isMultiSelecting: false, + }; + case 'TOGGLE_SELECTION': + return { + ...state, + isEnabled: action.isSelectionEnabled, + }; + } + + 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; +}; + +export default combineReducers( { + blocks, + isTyping, + isCaretWithinFormattedText, + blockSelection, + blocksMode, + blockListSettings, + insertionPoint, + template, + settings, + preferences, +} ); diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js new file mode 100644 index 00000000000000..d1b142530cff67 --- /dev/null +++ b/packages/block-editor/src/store/selectors.js @@ -0,0 +1,1395 @@ +/** + * External dependencies + */ +import { + castArray, + flatMap, + first, + get, + includes, + isArray, + isBoolean, + last, + map, + orderBy, + reduce, + some, +} from 'lodash'; +import createSelector from 'rememo'; + +/** + * WordPress dependencies + */ +import { + getBlockType, + getBlockTypes, + hasBlockSupport, + hasChildBlocksWithInserterSupport, +} from '@wordpress/blocks'; + +/*** + * Module constants + */ +export const INSERTER_UTILITY_HIGH = 3; +export const INSERTER_UTILITY_MEDIUM = 2; +export const INSERTER_UTILITY_LOW = 1; +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; + +/** + * 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 = []; + +/** + * Shared reference to an empty object for cases where it is important to avoid + * returning a new object reference on every invocation. + * + * @type {Object} + */ +const EMPTY_OBJECT = {}; + +/** + * Returns a new reference when the inner blocks of a given block client ID + * change. This is used exclusively as a memoized selector dependant, relying + * on this selector's shared return value and recursively those of its inner + * blocks defined as dependencies. This abuses mechanics of the selector + * memoization to return from the original selector function only when + * dependants change. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {*} A value whose reference will change only when inner blocks of + * the given block client ID change. + */ +export const getBlockDependantsCacheBust = createSelector( + () => [], + ( state, clientId ) => map( + getBlockOrder( state, clientId ), + ( innerBlockClientId ) => getBlock( state, innerBlockClientId ), + ), +); + +/** + * 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 const getBlockAttributes = createSelector( + ( state, clientId ) => { + const block = state.blocks.byClientId[ clientId ]; + if ( ! block ) { + return null; + } + + let attributes = state.blocks.attributes[ clientId ]; + + // Inject custom source attribute values. + // + // TODO: Create generic external sourcing pattern, not explicitly + // targeting meta attributes. + const type = getBlockType( block.name ); + if ( type ) { + attributes = reduce( type.attributes, ( result, value, key ) => { + if ( value.source === 'meta' ) { + if ( result === attributes ) { + result = { ...result }; + } + + result[ key ] = getPostMeta( state, value.meta ); + } + + return result; + }, attributes ); + } + + return attributes; + }, + ( state, clientId ) => [ + state.blocks.byClientId[ clientId ], + state.blocks.attributes[ clientId ], + getPostMeta( state ), + ] +); + +/** + * 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 ) => [ + ...getBlockAttributes.getDependants( state, clientId ), + getBlockDependantsCacheBust( state, 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 ], + ...getBlockAttributes.getDependants( state, 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 ) => [ + getPostMeta( 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; +} + +/** + * 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; +} + +/** + * 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; +} + +/** + * 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 ? 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 && start === end; +} + +/** + * 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 && start === end && !! state.blocks.byClientId[ start ] ? start : 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 const getBlockRootClientId = createSelector( + ( state, clientId ) => { + const { order } = state.blocks; + + for ( const rootClientId in order ) { + if ( includes( order[ rootClientId ], clientId ) ) { + return rootClientId; + } + } + + return null; + }, + ( state ) => [ + state.blocks.order, + ] +); + +/** + * 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 const getBlockHierarchyRootClientId = createSelector( + ( state, clientId ) => { + let rootClientId = clientId; + let current = clientId; + while ( rootClientId ) { + current = rootClientId; + rootClientId = getBlockRootClientId( state, current ); + } + + return current; + }, + ( state ) => [ + state.blocks.order, + ] +); + +/** + * 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 !== end || ! start ) { + return null; + } + + return state.blockSelection.initialPosition; +} + +/** + * 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 const getMultiSelectedBlockClientIds = createSelector( + ( state ) => { + const { start, end } = state.blockSelection; + if ( start === end ) { + return []; + } + + // 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 ); + if ( rootClientId === null ) { + return []; + } + + const blockOrder = getBlockOrder( state, rootClientId ); + const startIndex = blockOrder.indexOf( start ); + const endIndex = blockOrder.indexOf( end ); + + if ( startIndex > endIndex ) { + return blockOrder.slice( endIndex, startIndex + 1 ); + } + + return blockOrder.slice( startIndex, endIndex + 1 ); + }, + ( state ) => [ + state.blocks.order, + state.blockSelection.start, + state.blockSelection.end, + ], +); + +/** + * 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 ) => [ + ...getMultiSelectedBlockClientIds.getDependants( state ), + state.blocks.byClientId, + state.blocks.order, + state.blocks.attributes, + getPostMeta( state ), + ] +); + +/** + * 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; +} + +/** + * Checks if possibleAncestorId is an ancestor of possibleDescendentId. + * + * @param {Object} state Editor state. + * @param {string} possibleAncestorId Possible ancestor client ID. + * @param {string} possibleDescendentId Possible descent client ID. + * + * @return {boolean} True if possibleAncestorId is an ancestor + * of possibleDescendentId, and false otherwise. + */ +const isAncestorOf = createSelector( + ( state, possibleAncestorId, possibleDescendentId ) => { + let idToCheck = possibleDescendentId; + while ( possibleAncestorId !== idToCheck && idToCheck ) { + idToCheck = getBlockRootClientId( state, idToCheck ); + } + return possibleAncestorId === idToCheck; + }, + ( state ) => [ + state.blocks.order, + ], +); + +/** + * 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, + state.blockSelection.end, + ], +); +/** + * 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 === end ) { + return null; + } + return start || 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 === end ) { + return null; + } + return end || 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 !== end ) { + return false; + } + + return start === 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 !== end; +} + +/** + * 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 ) { + rootClientId = getBlockRootClientId( state, end ) || undefined; + index = getBlockIndex( state, end, 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 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 ); +}; + +/** + * Returns whether we can show a reusable block in the inserter + * + * @param {Object} state Global State + * @param {Object} reusableBlock Reusable block object + * @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 canIncludeReusableBlockInInserter = ( state, reusableBlock, rootClientId ) => { + if ( ! canInsertBlockTypeUnmemoized( state, 'core/block', rootClientId ) ) { + return false; + } + + const referencedBlockName = getBlockName( state, reusableBlock.clientId ); + if ( ! referencedBlockName ) { + return false; + } + + const referencedBlockType = getBlockType( referencedBlockName ); + if ( ! referencedBlockType ) { + return false; + } + + if ( ! canInsertBlockTypeUnmemoized( state, referencedBlockName, rootClientId ) ) { + return false; + } + + if ( isAncestorOf( state, reusableBlock.clientId, rootClientId ) ) { + return false; + } + + return true; +}; + +/** + * 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 ), + hasChildBlocksWithInserterSupport: hasChildBlocksWithInserterSupport( blockType.name ), + }; + }; + + const buildReusableBlockInserterItem = ( reusableBlock ) => { + const id = `core/block/${ reusableBlock.id }`; + + const referencedBlockName = getBlockName( state, reusableBlock.clientId ); + const referencedBlockType = getBlockType( referencedBlockName ); + + 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.icon, + category: 'reusable', + keywords: [], + isDisabled: false, + utility, + frecency, + }; + }; + + const blockTypeInserterItems = getBlockTypes() + .filter( ( blockType ) => canIncludeBlockTypeInInserter( state, blockType, rootClientId ) ) + .map( buildBlockTypeInserterItem ); + + const reusableBlockInserterItems = getReusableBlocks( state ) + .filter( ( block ) => canIncludeReusableBlockInInserter( state, block, rootClientId ) ) + .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 = some( + getReusableBlocks( state ), + ( block ) => canIncludeReusableBlockInInserter( state, block, rootClientId ) + ); + + 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 value of a post meta from the editor settings. + * + * @param {Object} state Global application state. + * @param {string} key Meta Key to retrieve + * + * @return {*} Meta value + */ +function getPostMeta( state, key ) { + if ( key === undefined ) { + return get( state, [ 'settings', '__experimentalMetaSource', 'value' ], EMPTY_OBJECT ); + } + + return get( state, [ 'settings', '__experimentalMetaSource', 'value', key ] ); +} + +/** + * 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 ); +} diff --git a/packages/block-editor/src/store/test/actions.js b/packages/block-editor/src/store/test/actions.js new file mode 100644 index 00000000000000..2ca6839dc57e22 --- /dev/null +++ b/packages/block-editor/src/store/test/actions.js @@ -0,0 +1,349 @@ +/** + * Internal dependencies + */ +import { + replaceBlocks, + startTyping, + stopTyping, + enterFormattedText, + exitFormattedText, + toggleSelection, + resetBlocks, + updateBlockAttributes, + updateBlock, + selectBlock, + selectPreviousBlock, + startMultiSelect, + stopMultiSelect, + multiSelect, + clearSelectedBlock, + replaceBlock, + insertBlock, + insertBlocks, + showInsertionPoint, + hideInsertionPoint, + mergeBlocks, + removeBlocks, + removeBlock, + toggleBlockMode, + 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 return the REPLACE_BLOCKS action', () => { + const block = { + clientId: 'ribs', + }; + + expect( replaceBlock( [ 'chicken' ], block ) ).toEqual( { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks: [ block ], + time: expect.any( Number ), + } ); + } ); + } ); + + describe( 'replaceBlocks', () => { + it( 'should return the REPLACE_BLOCKS action', () => { + const blocks = [ { + clientId: 'ribs', + } ]; + + expect( replaceBlocks( [ 'chicken' ], blocks ) ).toEqual( { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks, + time: expect.any( Number ), + } ); + } ); + } ); + + describe( 'insertBlock', () => { + it( 'should return the INSERT_BLOCKS action', () => { + const block = { + clientId: 'ribs', + }; + const index = 5; + expect( insertBlock( block, index, 'testclientid' ) ).toEqual( { + type: 'INSERT_BLOCKS', + blocks: [ block ], + index, + rootClientId: 'testclientid', + time: expect.any( Number ), + updateSelection: true, + } ); + } ); + } ); + + describe( 'insertBlocks', () => { + it( 'should return the INSERT_BLOCKS action', () => { + const blocks = [ { + clientId: 'ribs', + } ]; + const index = 3; + expect( insertBlocks( blocks, index, 'testclientid' ) ).toEqual( { + type: 'INSERT_BLOCKS', + blocks, + index, + rootClientId: 'testclientid', + time: expect.any( Number ), + updateSelection: true, + } ); + } ); + } ); + + 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( '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' }, + } ); + } ); + } ); +} ); diff --git a/packages/editor/src/store/test/array.js b/packages/block-editor/src/store/test/array.js similarity index 100% rename from packages/editor/src/store/test/array.js rename to packages/block-editor/src/store/test/array.js diff --git a/packages/block-editor/src/store/test/effects.js b/packages/block-editor/src/store/test/effects.js new file mode 100644 index 00000000000000..34300ed6d42ff8 --- /dev/null +++ b/packages/block-editor/src/store/test/effects.js @@ -0,0 +1,280 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + getBlockTypes, + unregisterBlockType, + registerBlockType, + createBlock, +} from '@wordpress/blocks'; +import { createRegistry } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import actions, { + updateSettings, + mergeBlocks, + replaceBlocks, + resetBlocks, + selectBlock, + 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 = { 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 = { + clientId: 'chicken', + name: 'core/test-block', + }; + const blockB = { + 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', { + merge( attributes, attributesToMerge ) { + return { + content: attributes.content + ' ' + attributesToMerge.content, + }; + }, + save: noop, + category: 'common', + title: 'test block', + } ); + const blockA = { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken' }, + }; + const blockB = { + clientId: 'ribs', + name: 'core/test-block', + attributes: { content: 'ribs' }, + }; + 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( 2 ); + expect( dispatch ).toHaveBeenCalledWith( selectBlock( 'chicken', -1 ) ); + expect( dispatch ).toHaveBeenCalledWith( { + ...replaceBlocks( [ 'chicken', 'ribs' ], [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken ribs' }, + } ] ), + time: expect.any( Number ), + } ); + } ); + + it( 'should not merge the blocks have different types without transformation', () => { + registerBlockType( 'core/test-block', { + merge( attributes, attributesToMerge ) { + return { + content: attributes.content + ' ' + attributesToMerge.content, + }; + }, + save: noop, + category: 'common', + title: 'test block', + } ); + registerBlockType( 'core/test-block-2', defaultBlockSettings ); + const blockA = { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken' }, + }; + const blockB = { + clientId: 'ribs', + name: 'core/test-block2', + attributes: { content: 'ribs' }, + }; + 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 ).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: { + content: { + 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 = { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken' }, + }; + const blockB = { + clientId: 'ribs', + name: 'core/test-block-2', + attributes: { content2: 'ribs' }, + }; + 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( 2 ); + // expect( dispatch ).toHaveBeenCalledWith( focusBlock( 'chicken', { offset: -1 } ) ); + expect( dispatch ).toHaveBeenCalledWith( { + ...replaceBlocks( [ 'chicken', 'ribs' ], [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken ribs' }, + } ] ), + time: expect.any( Number ), + } ); + } ); + } ); + + 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/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js new file mode 100644 index 00000000000000..778875b99ef599 --- /dev/null +++ b/packages/block-editor/src/store/test/reducer.js @@ -0,0 +1,1801 @@ +/** + * 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, +} 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' ); + } ); + + it( 'should return empty byClientId, attributes, order by default', () => { + const state = blocks( undefined, {} ); + + expect( state ).toEqual( { + byClientId: {}, + attributes: {}, + order: {}, + isPersistentChange: true, + } ); + } ); + + 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: [], + } ); + } ); + } ); + + 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' ], + } ); + } ); + + 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: [], + } ); + } ); + + 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: [], + } ); + } ); + + 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 state = blocks( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ nestedBlock.clientId ], + blocks: [ replacementBlock ], + } ); + + expect( state.order ).toEqual( { + '': [ wrapperBlock.clientId ], + [ wrapperBlock.clientId ]: [ replacementBlock.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: [], + } ); + + 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', + } ); + } ); + + 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, + } ); + } ); + + 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' ] ); + } ); + + 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 ]: [], + } ); + } ); + + 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.byClientId ).toEqual( { + ribs: { + clientId: 'ribs', + name: 'core/test-block', + }, + } ); + expect( state.attributes ).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.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( { + '': [], + } ); + } ); + + 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 not consider received blocks as persistent change', () => { + const state = blocks( undefined, { + type: 'RECEIVE_BLOCKS', + blocks: [ { + clientId: 'kumquat', + attributes: {}, + innerBlocks: [], + } ], + } ); + + expect( state.isPersistentChange ).toBe( false ); + } ); + } ); + } ); + } ); + + 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: 'kumquat', + end: 'kumquat', + initialPosition: -1, + isMultiSelecting: false, + isEnabled: true, + } ); + } ); + + it( 'should set multi selection', () => { + const original = deepFreeze( { isMultiSelecting: false } ); + const state = blockSelection( original, { + type: 'MULTI_SELECT', + start: 'ribs', + end: 'chicken', + } ); + + expect( state ).toEqual( { + start: 'ribs', + end: 'chicken', + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should set continuous multi selection', () => { + const original = deepFreeze( { isMultiSelecting: true } ); + const state = blockSelection( original, { + type: 'MULTI_SELECT', + start: 'ribs', + end: 'chicken', + } ); + + expect( state ).toEqual( { + start: 'ribs', + end: 'chicken', + initialPosition: null, + isMultiSelecting: true, + } ); + } ); + + it( 'should start multi selection', () => { + const original = deepFreeze( { start: 'ribs', end: 'ribs', isMultiSelecting: false } ); + const state = blockSelection( original, { + type: 'START_MULTI_SELECT', + } ); + + expect( state ).toEqual( { + start: 'ribs', + end: 'ribs', + initialPosition: null, + isMultiSelecting: true, + } ); + } ); + + it( 'should return same reference if already multi-selecting', () => { + const original = deepFreeze( { start: 'ribs', end: '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: 'ribs', end: 'chicken', isMultiSelecting: true } ); + const state = blockSelection( original, { + type: 'STOP_MULTI_SELECT', + } ); + + expect( state ).toEqual( { + start: 'ribs', + end: 'chicken', + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should return same reference if already ended multi-selecting', () => { + const original = deepFreeze( { start: 'ribs', end: '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: 'ribs', end: 'ribs', isMultiSelecting: true } ); + const state = blockSelection( original, { + type: 'STOP_MULTI_SELECT', + } ); + + expect( state ).toEqual( { + start: 'ribs', + end: 'ribs', + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should not update the state if the block is already selected', () => { + const original = deepFreeze( { start: 'ribs', end: 'ribs' } ); + + const state1 = blockSelection( original, { + type: 'SELECT_BLOCK', + clientId: 'ribs', + } ); + + expect( state1 ).toBe( original ); + } ); + + it( 'should unset multi selection', () => { + const original = deepFreeze( { start: 'ribs', end: 'chicken' } ); + + const state1 = blockSelection( original, { + type: 'CLEAR_SELECTED_BLOCK', + } ); + + expect( state1 ).toEqual( { + start: null, + end: null, + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should return same reference if clearing selection but no selection', () => { + const original = deepFreeze( { start: null, end: null, isMultiSelecting: false } ); + + const state1 = blockSelection( original, { + type: 'CLEAR_SELECTED_BLOCK', + } ); + + expect( state1 ).toBe( original ); + } ); + + it( 'should select inserted block', () => { + const original = deepFreeze( { start: 'ribs', end: 'chicken' } ); + + const state3 = blockSelection( original, { + type: 'INSERT_BLOCKS', + blocks: [ { + clientId: 'ribs', + name: 'core/freeform', + } ], + updateSelection: true, + } ); + + expect( state3 ).toEqual( { + start: 'ribs', + end: 'ribs', + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should not select inserted block if updateSelection flag is false', () => { + const original = deepFreeze( { start: 'a', end: 'b' } ); + + const state3 = blockSelection( original, { + type: 'INSERT_BLOCKS', + blocks: [ { + clientId: 'ribs', + name: 'core/freeform', + } ], + updateSelection: false, + } ); + + expect( state3 ).toEqual( { + start: 'a', + end: 'b', + } ); + } ); + + it( 'should not update the state if the block moved is already selected', () => { + const original = deepFreeze( { start: 'ribs', end: '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: 'chicken', end: 'chicken' } ); + const state = blockSelection( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks: [ { + clientId: 'wings', + name: 'core/freeform', + } ], + } ); + + expect( state ).toEqual( { + start: 'wings', + end: 'wings', + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should not replace the selected block if we keep it at the end when replacing blocks', () => { + const original = deepFreeze( { start: 'wings', end: '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: 'chicken', end: 'chicken' } ); + const state = blockSelection( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks: [ + { + clientId: 'chicken', + name: 'core/freeform', + }, + { + clientId: 'wings', + name: 'core/freeform', + } ], + } ); + + expect( state ).toEqual( { + start: 'wings', + end: 'wings', + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should reset if replacing with empty set', () => { + const original = deepFreeze( { start: 'chicken', end: 'chicken' } ); + const state = blockSelection( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks: [], + } ); + + expect( state ).toEqual( { + start: null, + end: null, + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should keep the selected block', () => { + const original = deepFreeze( { start: 'chicken', end: '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: 'chicken', + end: 'chicken', + initialPosition: null, + isMultiSelecting: false, + } ); + const state = blockSelection( original, { + type: 'REMOVE_BLOCKS', + clientIds: [ 'chicken' ], + } ); + + expect( state ).toEqual( { + start: null, + end: null, + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should keep the selection if we are not removing the selected block', () => { + const original = deepFreeze( { + start: 'chicken', + end: 'chicken', + initialPosition: null, + isMultiSelecting: false, + } ); + const state = blockSelection( original, { + type: 'REMOVE_BLOCKS', + clientIds: [ 'ribs' ], + } ); + + 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( {} ); + } ); + } ); +} ); diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js new file mode 100644 index 00000000000000..bcda13ad755ff6 --- /dev/null +++ b/packages/block-editor/src/store/test/selectors.js @@ -0,0 +1,2321 @@ +/** + * 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 { + getBlockDependantsCacheBust, + getBlockName, + getBlock, + getBlocks, + getBlockCount, + getClientIdsWithDescendants, + getClientIdsOfDescendants, + hasSelectedBlock, + getSelectedBlock, + getSelectedBlockClientId, + getBlockRootClientId, + getBlockHierarchyRootClientId, + getGlobalBlockCount, + 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, + 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 ) => { props.attributes.content }, + 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( 'getBlockDependantsCacheBust', () => { + const rootBlock = { clientId: 123, name: 'core/paragraph' }; + const rootBlockAttributes = {}; + const rootOrder = [ 123 ]; + + it( 'returns an unchanging reference', () => { + const rootBlockOrder = []; + + const state = { + blocks: { + byClientId: { + 123: rootBlock, + }, + attributes: { + 123: rootBlockAttributes, + }, + order: { + '': rootOrder, + 123: rootBlockOrder, + }, + }, + }; + + const nextState = { + blocks: { + byClientId: { + 123: rootBlock, + }, + attributes: { + 123: rootBlockAttributes, + }, + order: { + '': rootOrder, + 123: rootBlockOrder, + }, + }, + }; + + expect( + getBlockDependantsCacheBust( state, 123 ) + ).toBe( getBlockDependantsCacheBust( nextState, 123 ) ); + } ); + + it( 'returns a new reference on added inner block', () => { + const state = { + blocks: { + byClientId: { + 123: rootBlock, + }, + attributes: { + 123: rootBlockAttributes, + }, + order: { + '': rootOrder, + 123: [], + }, + }, + }; + + const nextState = { + blocks: { + byClientId: { + 123: rootBlock, + 456: { clientId: 456, name: 'core/paragraph' }, + }, + attributes: { + 123: rootBlockAttributes, + 456: {}, + }, + order: { + '': rootOrder, + 123: [ 456 ], + 456: [], + }, + }, + }; + + expect( + getBlockDependantsCacheBust( state, 123 ) + ).not.toBe( getBlockDependantsCacheBust( nextState, 123 ) ); + } ); + + it( 'returns an unchanging reference on unchanging inner block', () => { + const rootBlockOrder = [ 456 ]; + const childBlock = { clientId: 456, name: 'core/paragraph' }; + const childBlockAttributes = {}; + const childBlockOrder = []; + + const state = { + blocks: { + byClientId: { + 123: rootBlock, + 456: childBlock, + }, + attributes: { + 123: rootBlockAttributes, + 456: childBlockAttributes, + }, + order: { + '': rootOrder, + 123: rootBlockOrder, + 456: childBlockOrder, + }, + }, + }; + + const nextState = { + blocks: { + byClientId: { + 123: rootBlock, + 456: childBlock, + }, + attributes: { + 123: rootBlockAttributes, + 456: childBlockAttributes, + }, + order: { + '': rootOrder, + 123: rootBlockOrder, + 456: childBlockOrder, + }, + }, + }; + + expect( + getBlockDependantsCacheBust( state, 123 ) + ).toBe( getBlockDependantsCacheBust( nextState, 123 ) ); + } ); + + it( 'returns a new reference on updated inner block', () => { + const rootBlockOrder = [ 456 ]; + const childBlockOrder = []; + + const state = { + blocks: { + byClientId: { + 123: rootBlock, + 456: { clientId: 456, name: 'core/paragraph' }, + }, + attributes: { + 123: rootBlockAttributes, + 456: {}, + }, + order: { + '': rootOrder, + 123: rootBlockOrder, + 456: childBlockOrder, + }, + }, + }; + + const nextState = { + blocks: { + byClientId: { + 123: rootBlock, + 456: { clientId: 456, name: 'core/paragraph' }, + }, + attributes: { + 123: rootBlockAttributes, + 456: { content: [ 'foo' ] }, + }, + order: { + '': rootOrder, + 123: rootBlockOrder, + 456: childBlockOrder, + }, + }, + }; + + expect( + getBlockDependantsCacheBust( state, 123 ) + ).not.toBe( getBlockDependantsCacheBust( nextState, 123 ) ); + } ); + + it( 'returns a new reference on updated grandchild inner block', () => { + const rootBlockOrder = [ 456 ]; + const childBlock = { clientId: 456, name: 'core/paragraph' }; + const childBlockAttributes = {}; + const childBlockOrder = [ 789 ]; + const grandChildBlockOrder = []; + + const state = { + blocks: { + byClientId: { + 123: rootBlock, + 456: childBlock, + 789: { clientId: 789, name: 'core/paragraph' }, + }, + attributes: { + 123: rootBlockAttributes, + 456: childBlockAttributes, + 789: {}, + }, + order: { + '': rootOrder, + 123: rootBlockOrder, + 456: childBlockOrder, + 789: grandChildBlockOrder, + }, + }, + }; + + const nextState = { + blocks: { + byClientId: { + 123: rootBlock, + 456: childBlock, + 789: { clientId: 789, name: 'core/paragraph' }, + }, + attributes: { + 123: rootBlockAttributes, + 456: childBlockAttributes, + 789: { content: [ 'foo' ] }, + }, + order: { + '': rootOrder, + 123: rootBlockOrder, + 456: childBlockOrder, + 789: grandChildBlockOrder, + }, + }, + }; + + expect( + getBlockDependantsCacheBust( state, 123 ) + ).not.toBe( getBlockDependantsCacheBust( nextState, 123 ) ); + } ); + } ); + + describe( 'getBlockName', () => { + it( 'returns null if no block by clientId', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + order: {}, + }, + }; + + 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': [], + }, + }, + }; + + 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: [], + }, + }, + }; + + 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: {}, + }, + }; + + 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: [], + }, + }, + }; + + expect( getBlock( state, 123 ) ).toEqual( { + clientId: 123, + name: 'core/paragraph', + attributes: {}, + innerBlocks: [ { + clientId: 456, + name: 'core/paragraph', + attributes: {}, + innerBlocks: [], + } ], + } ); + } ); + + it( 'should merge meta attributes for the block', () => { + registerBlockType( 'core/meta-block', { + save: ( props ) => props.attributes.text, + category: 'common', + title: 'test block', + attributes: { + foo: { + type: 'string', + source: 'meta', + meta: 'foo', + }, + }, + } ); + + const state = { + settings: { + __experimentalMetaSource: { + value: { + foo: 'bar', + }, + }, + }, + blocks: { + byClientId: { + 123: { clientId: 123, name: 'core/meta-block' }, + }, + attributes: { + 123: {}, + }, + order: { + '': [ 123 ], + 123: [], + }, + }, + }; + + expect( getBlock( state, 123 ) ).toEqual( { + clientId: 123, + name: 'core/meta-block', + attributes: { + foo: 'bar', + }, + innerBlocks: [], + } ); + + unregisterBlockType( 'core/meta-block' ); + } ); + } ); + + 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 ], + }, + }, + }; + + 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' ], + }, + }, + }; + 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' ], + }, + }, + }; + 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 ], + }, + }, + }; + + expect( getBlockCount( state, '123' ) ).toBe( 2 ); + } ); + } ); + + describe( 'hasSelectedBlock', () => { + it( 'should return false if no selection', () => { + const state = { + blockSelection: { + start: null, + end: null, + }, + }; + + expect( hasSelectedBlock( state ) ).toBe( false ); + } ); + + it( 'should return false if multi-selection', () => { + const state = { + blockSelection: { + start: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + end: '9db792c6-a25a-495d-adbd-97d56a4c4189', + }, + }; + + expect( hasSelectedBlock( state ) ).toBe( false ); + } ); + + it( 'should return true if singular selection', () => { + const state = { + blockSelection: { + start: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + end: '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 ], + }, + }, + }; + + 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: {}, + }, + }; + 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: null, end: null }, + }; + + expect( getSelectedBlockClientId( state ) ).toBe( null ); + } ); + + it( 'should return null if there is multi selection', () => { + const state = { + blockSelection: { start: 23, end: 123 }, + }; + + expect( getSelectedBlockClientId( state ) ).toBe( null ); + } ); + + it( 'should return the selected block ClientId', () => { + const state = { + blocks: { + byClientId: { + 23: { + name: 'fake block', + }, + }, + }, + blockSelection: { start: 23, end: 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: [], + }, + }, + blockSelection: { start: null, end: null }, + }; + + 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: [], + }, + }, + blockSelection: { start: 23, end: 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: [], + }, + }, + blockSelection: { start: 23, end: 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: {}, + }, + }; + + expect( getBlockRootClientId( state, 56 ) ).toBeNull(); + } ); + + it( 'should return root ClientId relative the block ClientId', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + }, + }; + + expect( getBlockRootClientId( state, 56 ) ).toBe( '123' ); + } ); + } ); + + describe( 'getBlockHierarchyRootClientId', () => { + it( 'should return the given block if the block has no parents', () => { + const state = { + blocks: { + order: {}, + }, + }; + + expect( getBlockHierarchyRootClientId( state, 56 ) ).toBe( 56 ); + } ); + + it( 'should return root ClientId relative the block ClientId', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + }, + }; + + expect( getBlockHierarchyRootClientId( state, 56 ) ).toBe( '123' ); + } ); + + it( 'should return the top level root ClientId relative the block ClientId', () => { + const state = { + blocks: { + order: { + '': [ '123', '23' ], + 123: [ '456', '56' ], + 56: [ '12' ], + }, + }, + }; + + expect( getBlockHierarchyRootClientId( state, '12' ) ).toBe( '123' ); + } ); + } ); + + describe( 'getMultiSelectedBlockClientIds', () => { + it( 'should return empty if there is no multi selection', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + }, + blockSelection: { start: null, end: null }, + }; + + expect( getMultiSelectedBlockClientIds( state ) ).toEqual( [] ); + } ); + + it( 'should return selected block clientIds if there is multi selection', () => { + const state = { + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + }, + blockSelection: { start: 2, end: 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 ], + }, + }, + blockSelection: { start: 7, end: 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: {}, + }, + blockSelection: { start: null, end: null }, + }; + + expect( + getMultiSelectedBlocks( state ) + ).toBe( getMultiSelectedBlocks( state ) ); + } ); + } ); + + describe( 'getMultiSelectedBlocksStartClientId', () => { + it( 'returns null if there is no multi selection', () => { + const state = { + blockSelection: { start: null, end: null }, + }; + + expect( getMultiSelectedBlocksStartClientId( state ) ).toBeNull(); + } ); + + it( 'returns multi selection start', () => { + const state = { + blockSelection: { start: 2, end: 4 }, + }; + + expect( getMultiSelectedBlocksStartClientId( state ) ).toBe( 2 ); + } ); + } ); + + describe( 'getMultiSelectedBlocksEndClientId', () => { + it( 'returns null if there is no multi selection', () => { + const state = { + blockSelection: { start: null, end: null }, + }; + + expect( getMultiSelectedBlocksEndClientId( state ) ).toBeNull(); + } ); + + it( 'returns multi selection end', () => { + const state = { + blockSelection: { start: 2, end: 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 ], + }, + }, + }; + + 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 ], + }, + }, + }; + + expect( getBlockOrder( state, '123' ) ).toEqual( [ 456 ] ); + } ); + } ); + + describe( 'getBlockIndex', () => { + it( 'should return the block order', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + }, + }; + + expect( getBlockIndex( state, 23 ) ).toBe( 1 ); + } ); + + it( 'should return the block order (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + }, + }; + + expect( getBlockIndex( state, 56, '123' ) ).toBe( 1 ); + } ); + } ); + + describe( 'getPreviousBlockClientId', () => { + it( 'should return the previous block', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + }, + }; + + expect( getPreviousBlockClientId( state, 23 ) ).toEqual( 123 ); + } ); + + it( 'should return the previous block (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + }, + }; + + expect( getPreviousBlockClientId( state, 56, '123' ) ).toEqual( 456 ); + } ); + + it( 'should return null for the first block', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + }, + }; + + expect( getPreviousBlockClientId( state, 123 ) ).toBeNull(); + } ); + + it( 'should return null for the first block (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + }, + }; + + expect( getPreviousBlockClientId( state, 456, '123' ) ).toBeNull(); + } ); + } ); + + describe( 'getNextBlockClientId', () => { + it( 'should return the following block', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + }, + }; + + expect( getNextBlockClientId( state, 123 ) ).toEqual( 23 ); + } ); + + it( 'should return the following block (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + }, + }; + + expect( getNextBlockClientId( state, 456, '123' ) ).toEqual( 56 ); + } ); + + it( 'should return null for the last block', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + }, + }; + + expect( getNextBlockClientId( state, 23 ) ).toBeNull(); + } ); + + it( 'should return null for the last block (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + }, + }; + + expect( getNextBlockClientId( state, 56, '123' ) ).toBeNull(); + } ); + } ); + + describe( 'isBlockSelected', () => { + it( 'should return true if the block is selected', () => { + const state = { + blockSelection: { start: 123, end: 123 }, + }; + + expect( isBlockSelected( state, 123 ) ).toBe( true ); + } ); + + it( 'should return false if a multi-selection range exists', () => { + const state = { + blockSelection: { start: 123, end: 124 }, + }; + + expect( isBlockSelected( state, 123 ) ).toBe( false ); + } ); + + it( 'should return false if the block is not selected', () => { + const state = { + blockSelection: { start: null, end: null }, + }; + + 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: 5, end: 5 }, + blocks: { + order: { + 4: [ 3, 2, 1 ], + }, + }, + }; + + 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: 3, end: 3 }, + blocks: { + order: { + 4: [ 3, 2, 1 ], + }, + }, + }; + + 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 ], + }, + }, + blockSelection: { start: 2, end: 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 ], + }, + }, + blockSelection: { start: 5, end: 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: 5, end: 3 }, + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + }, + }; + + expect( isBlockWithinSelection( state, 4 ) ).toBe( true ); + } ); + + it( 'should return false if the block is the last selected', () => { + const state = { + blockSelection: { start: 5, end: 3 }, + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + }, + }; + + expect( isBlockWithinSelection( state, 3 ) ).toBe( false ); + } ); + + it( 'should return false if the block is not selected', () => { + const state = { + blockSelection: { start: 5, end: 3 }, + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + }, + }; + + expect( isBlockWithinSelection( state, 2 ) ).toBe( false ); + } ); + + it( 'should return false if there is no selection', () => { + const state = { + blockSelection: {}, + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + }, + }; + + expect( isBlockWithinSelection( state, 4 ) ).toBe( false ); + } ); + } ); + + describe( 'hasMultiSelection', () => { + it( 'should return false if no selection', () => { + const state = { + blockSelection: { + start: null, + end: null, + }, + }; + + expect( hasMultiSelection( state ) ).toBe( false ); + } ); + + it( 'should return false if singular selection', () => { + const state = { + blockSelection: { + start: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + end: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + }, + }; + + expect( hasMultiSelection( state ) ).toBe( false ); + } ); + + it( 'should return true if multi-selection', () => { + const state = { + blockSelection: { + start: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + end: '9db792c6-a25a-495d-adbd-97d56a4c4189', + }, + }; + + expect( hasMultiSelection( state ) ).toBe( true ); + } ); + } ); + + describe( 'isBlockMultiSelected', () => { + const state = { + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + }, + blockSelection: { start: 2, end: 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 ], + }, + }, + blockSelection: { start: 2, end: 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: 'clientId2', + end: 'clientId2', + }, + blocks: { + byClientId: { + clientId1: { clientId: 'clientId1' }, + clientId2: { clientId: 'clientId2' }, + }, + attributes: { + clientId1: {}, + clientId2: {}, + }, + order: { + '': [ 'clientId1' ], + clientId1: [ 'clientId2' ], + clientId2: [], + }, + }, + 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: 'clientId1', + end: 'clientId1', + }, + blocks: { + byClientId: { + clientId1: { clientId: 'clientId1' }, + }, + attributes: { + clientId1: {}, + }, + order: { + '': [ 'clientId1' ], + 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: 'clientId2', + end: 'clientId2', + }, + blocks: { + byClientId: { + clientId1: { clientId: 'clientId1' }, + clientId2: { clientId: 'clientId2' }, + }, + attributes: { + clientId1: {}, + clientId2: {}, + }, + order: { + '': [ 'clientId1' ], + clientId1: [ 'clientId2' ], + clientId2: [], + }, + }, + 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: 'clientId1', + end: 'clientId2', + }, + blocks: { + byClientId: { + clientId1: { clientId: 'clientId1' }, + clientId2: { clientId: 'clientId2' }, + }, + attributes: { + clientId1: {}, + clientId2: {}, + }, + order: { + '': [ 'clientId1', 'clientId2' ], + 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: null, + end: null, + }, + blocks: { + byClientId: { + clientId1: { clientId: 'clientId1' }, + clientId2: { clientId: 'clientId2' }, + }, + attributes: { + clientId1: {}, + clientId2: {}, + }, + order: { + '': [ 'clientId1', 'clientId2' ], + 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: { + block1: { name: 'core/test-block-a' }, + }, + attributes: { + block1: {}, + }, + order: {}, + }, + settings: { + __experimentalReusableBlocks: [ + { id: 1, isTemporary: false, clientId: 'block1', title: 'Reusable Block 1' }, + ], + }, + preferences: { + insertUsage: {}, + }, + 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, + hasChildBlocksWithInserterSupport: false, + } ); + 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 not list a reusable block item if it is being inserted inside it self', () => { + const state = { + blocks: { + byClientId: { + block1ref: { + name: 'core/block', + clientId: 'block1ref', + }, + itselfBlock1: { name: 'core/test-block-a' }, + itselfBlock2: { name: 'core/test-block-b' }, + }, + attributes: { + block1ref: { + attributes: { + ref: 1, + }, + }, + itselfBlock1: {}, + itselfBlock2: {}, + }, + order: { + '': [ 'block1ref' ], + }, + }, + settings: { + __experimentalReusableBlocks: [ + { id: 1, isTemporary: false, clientId: 'itselfBlock1', title: 'Reusable Block 1' }, + { id: 2, isTemporary: false, clientId: 'itselfBlock2', title: 'Reusable Block 2' }, + ], + }, + preferences: { + insertUsage: {}, + }, + blockListSettings: {}, + }; + const items = getInserterItems( state, 'itselfBlock1' ); + const reusableBlockItems = filter( items, [ 'name', 'core/block' ] ); + expect( reusableBlockItems ).toHaveLength( 1 ); + expect( reusableBlockItems[ 0 ] ).toEqual( { + id: 'core/block/2', + name: 'core/block', + initialAttributes: { ref: 2 }, + title: 'Reusable Block 2', + icon: { + src: 'test', + }, + category: 'reusable', + keywords: [], + isDisabled: false, + utility: 0, + frecency: 0, + } ); + } ); + + it( 'should not list a reusable block item if it is being inserted inside a descendent', () => { + const state = { + blocks: { + byClientId: { + block2ref: { + name: 'core/block', + clientId: 'block1ref', + }, + referredBlock1: { name: 'core/test-block-a' }, + referredBlock2: { name: 'core/test-block-b' }, + childReferredBlock2: { name: 'core/test-block-a' }, + grandchildReferredBlock2: { name: 'core/test-block-b' }, + }, + attributes: { + block2ref: { + attributes: { + ref: 2, + }, + }, + referredBlock1: {}, + referredBlock2: {}, + childReferredBlock2: {}, + grandchildReferredBlock2: {}, + }, + order: { + '': [ 'block2ref' ], + referredBlock2: [ 'childReferredBlock2' ], + childReferredBlock2: [ 'grandchildReferredBlock2' ], + }, + }, + + settings: { + __experimentalReusableBlocks: [ + { id: 1, isTemporary: false, clientId: 'referredBlock1', title: 'Reusable Block 1' }, + { id: 2, isTemporary: false, clientId: 'referredBlock2', title: 'Reusable Block 2' }, + ], + }, + preferences: { + insertUsage: {}, + }, + blockListSettings: {}, + }; + const items = getInserterItems( state, 'grandchildReferredBlock2' ); + const reusableBlockItems = filter( items, [ 'name', 'core/block' ] ); + expect( reusableBlockItems ).toHaveLength( 1 ); + expect( reusableBlockItems[ 0 ] ).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: { + block1: { name: 'core/test-block-a' }, + block2: { name: 'core/test-block-a' }, + }, + attributes: { + block1: {}, + block2: {}, + }, + order: {}, + }, + settings: { + __experimentalReusableBlocks: [ + { id: 1, isTemporary: false, clientId: 'block1', title: 'Reusable Block 1' }, + { id: 2, isTemporary: false, clientId: 'block2', title: 'Reusable Block 2' }, + ], + }, + 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: { + block1: { name: 'core/test-block-a' }, + block2: { name: 'core/test-block-a' }, + block3: { name: 'core/test-block-a' }, + block4: { name: 'core/test-block-a' }, + }, + attributes: { + block1: {}, + block2: {}, + block3: {}, + block4: {}, + }, + order: { + '': [ 'block3', 'block4' ], + }, + }, + settings: { + __experimentalReusableBlocks: [ + { id: 1, isTemporary: false, clientId: 'block1', title: 'Reusable Block 1' }, + { id: 2, isTemporary: false, clientId: 'block2', title: 'Reusable Block 2' }, + ], + }, + 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' ], + }, + }, + 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: {}, + }, + 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: {}, + }, + 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' ], + }, + }, + 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 ); + } ); + } ); +} ); diff --git a/packages/block-editor/src/style.scss b/packages/block-editor/src/style.scss new file mode 100644 index 00000000000000..deedea8be7ec1f --- /dev/null +++ b/packages/block-editor/src/style.scss @@ -0,0 +1,33 @@ +@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-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/color-palette/control.scss"; +@import "./components/contrast-checker/style.scss"; +@import "./components/default-block-appender/style.scss"; +@import "./components/font-sizes/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"; diff --git a/packages/block-editor/src/utils/dom.js b/packages/block-editor/src/utils/dom.js new file mode 100644 index 00000000000000..f6d3328d2fd86b --- /dev/null +++ b/packages/block-editor/src/utils/dom.js @@ -0,0 +1,77 @@ +/** + * 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. + * + * @return {Element} Block DOM node. + */ +export function getBlockDOMNode( clientId ) { + return document.querySelector( '[data-block="' + clientId + '"]' ); +} + +/** + * 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/packages/block-editor/src/utils/test/dom.js b/packages/block-editor/src/utils/test/dom.js new file mode 100644 index 00000000000000..45533fa436a199 --- /dev/null +++ b/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 = ( + '
    ' + + '
    ' + + '

    This is a test.

    ' + + '
    ' + + '
    ' + ); + + 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 = ( + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + ); + + const blockNode = wrapper.firstChild; + expect( hasInnerBlocksContext( blockNode ) ).toBe( true ); + } ); +} ); diff --git a/packages/block-library/CHANGELOG.md b/packages/block-library/CHANGELOG.md index e27ca871be3848..f46b47f579afe4 100644 --- a/packages/block-library/CHANGELOG.md +++ b/packages/block-library/CHANGELOG.md @@ -1,3 +1,17 @@ +## 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) diff --git a/packages/block-library/README.md b/packages/block-library/README.md index d9f50a3432a55b..78573bbf753e7d 100644 --- a/packages/block-library/README.md +++ b/packages/block-library/README.md @@ -12,7 +12,17 @@ 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)._ -## Usage +## API + + + +### registerCoreBlocks + +[src/index.js#L70-L132](src/index.js#L70-L132) + +Function to register core blocks provided by the block editor. + +**Usage** ```js import { registerCoreBlocks } from '@wordpress/block-library'; @@ -20,4 +30,7 @@ import { registerCoreBlocks } from '@wordpress/block-library'; registerCoreBlocks(); ``` + + +

    Code is Poetry.

    diff --git a/packages/block-library/package.json b/packages/block-library/package.json index ad9c4514349cc4..5a497ce617376f 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-library", - "version": "2.2.9", + "version": "2.3.0", "description": "Block library for the WordPress editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -11,7 +11,8 @@ "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/block-library/README.md", "repository": { "type": "git", - "url": "https://github.com/WordPress/gutenberg.git" + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/block-library" }, "bugs": { "url": "https://github.com/WordPress/gutenberg/issues" @@ -20,9 +21,10 @@ "module": "build-module/index.js", "react-native": "src/index", "dependencies": { - "@babel/runtime": "^7.0.0", + "@babel/runtime": "^7.3.1", "@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", @@ -36,15 +38,11 @@ "@wordpress/keycodes": "file:../keycodes", "@wordpress/viewport": "file:../viewport", "classnames": "^2.2.5", - "lodash": "^4.17.10", + "fast-average-color": "4.3.0", + "lodash": "^4.17.11", "memize": "^1.0.5", "url": "^0.11.0" }, - "devDependencies": { - "deep-freeze": "^0.0.1", - "enzyme": "^3.7.0", - "react-test-renderer": "^16.6.3" - }, "publishConfig": { "access": "public" } diff --git a/packages/block-library/src/archives/edit.js b/packages/block-library/src/archives/edit.js index b59f4dd04d930f..51f9df96f147fc 100644 --- a/packages/block-library/src/archives/edit.js +++ b/packages/block-library/src/archives/edit.js @@ -8,16 +8,12 @@ import { Disabled, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ import { InspectorControls, BlockAlignmentToolbar, BlockControls, - ServerSideRender, -} from '@wordpress/editor'; +} from '@wordpress/block-editor'; +import { ServerSideRender } from '@wordpress/editor'; export default function ArchivesEdit( { attributes, setAttributes } ) { const { align, showPostCounts, displayAsDropdown } = attributes; diff --git a/packages/block-library/src/archives/index.js b/packages/block-library/src/archives/index.js index 8dfca64a8823cd..b523701fcfae49 100644 --- a/packages/block-library/src/archives/index.js +++ b/packages/block-library/src/archives/index.js @@ -16,7 +16,7 @@ export const settings = { description: __( 'Display a monthly archive of your posts.' ), - icon: , + icon: , category: 'widgets', diff --git a/packages/block-library/src/audio/edit.js b/packages/block-library/src/audio/edit.js index ee2966208d015b..d222b42ae6a641 100644 --- a/packages/block-library/src/audio/edit.js +++ b/packages/block-library/src/audio/edit.js @@ -1,25 +1,31 @@ /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; +import { getBlobByURL, isBlobURL } from '@wordpress/blob'; import { Disabled, IconButton, PanelBody, SelectControl, - Toolbar, ToggleControl, + Toolbar, withNotices, } from '@wordpress/components'; -import { Component, Fragment } from '@wordpress/element'; import { BlockControls, + BlockIcon, InspectorControls, MediaPlaceholder, RichText, - mediaUpload, -} from '@wordpress/editor'; -import { getBlobByURL, isBlobURL } from '@wordpress/blob'; +} from '@wordpress/block-editor'; +import { mediaUpload } from '@wordpress/editor'; +import { Component, Fragment } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import icon from './icon'; /** * Internal dependencies @@ -115,7 +121,7 @@ class AudioEdit extends Component { if ( editing ) { return ( } className={ className } onSelect={ onSelectAudio } onSelectURL={ this.onSelectURL } diff --git a/packages/block-library/src/audio/icon.js b/packages/block-library/src/audio/icon.js new file mode 100644 index 00000000000000..4b871506bf4788 --- /dev/null +++ b/packages/block-library/src/audio/icon.js @@ -0,0 +1,6 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default ; diff --git a/packages/block-library/src/audio/index.js b/packages/block-library/src/audio/index.js index 51a307102397d9..f51937c8d46145 100644 --- a/packages/block-library/src/audio/index.js +++ b/packages/block-library/src/audio/index.js @@ -1,16 +1,16 @@ /** * WordPress dependencies */ +import { createBlobURL } from '@wordpress/blob'; +import { createBlock } from '@wordpress/blocks'; +import { RichText } from '@wordpress/block-editor'; import { __ } from '@wordpress/i18n'; -import { RichText } from '@wordpress/editor'; -import { SVG, Path } from '@wordpress/components'; /** * Internal dependencies */ import edit from './edit'; -import { createBlock } from '@wordpress/blocks'; -import { createBlobURL } from '@wordpress/blob'; +import icon from './icon'; export const name = 'core/audio'; @@ -19,7 +19,7 @@ export const settings = { description: __( 'Embed a simple audio player.' ), - icon: , + icon, category: 'common', @@ -77,6 +77,36 @@ export const settings = { 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: 'srting', + shortcode: ( { named: { autoplay } } ) => { + return autoplay; + }, + }, + preload: { + type: 'string', + shortcode: ( { named: { preload } } ) => { + return preload; + }, + }, + }, + }, ], }, diff --git a/packages/block-library/src/audio/test/__snapshots__/index.js.snap b/packages/block-library/src/audio/test/__snapshots__/index.js.snap deleted file mode 100644 index e064799f81a1d3..00000000000000 --- a/packages/block-library/src/audio/test/__snapshots__/index.js.snap +++ /dev/null @@ -1,102 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`core/audio block edit matches snapshot 1`] = ` -
    -
    - - Audio -
    -
    - Drag an audio, upload a new one or select a file from your library. -
    -
    -
    -
    - - - Drop files to upload - -
    -
    -
    - - -
    -
    - -
    -
    -
    -`; diff --git a/packages/block-library/src/audio/test/index.js b/packages/block-library/src/audio/test/index.js deleted file mode 100644 index a5611a3dd8ce6a..00000000000000 --- a/packages/block-library/src/audio/test/index.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Internal dependencies - */ -import { name, settings } from '../'; -import { blockEditRender } from '../../test/helpers'; - -describe( 'core/audio', () => { - test( 'block edit matches snapshot', () => { - const wrapper = blockEditRender( name, settings ); - - expect( wrapper ).toMatchSnapshot(); - } ); -} ); diff --git a/packages/block-library/src/block/edit-panel/index.js b/packages/block-library/src/block/edit-panel/index.js index e91c76cc6bf52e..eb620a877b63a1 100644 --- a/packages/block-library/src/block/edit-panel/index.js +++ b/packages/block-library/src/block/edit-panel/index.js @@ -53,7 +53,7 @@ class ReusableBlockEditPanel extends Component { } render() { - const { isEditing, title, isSaving, onEdit, instanceId } = this.props; + const { isEditing, title, isSaving, isEditDisabled, onEdit, instanceId } = this.props; return ( @@ -66,6 +66,7 @@ class ReusableBlockEditPanel extends Component { ref={ this.editButton } isLarge className="reusable-block-edit-panel__button" + disabled={ isEditDisabled } onClick={ onEdit } > { __( 'Edit' ) } diff --git a/packages/block-library/src/block/edit-panel/style.scss b/packages/block-library/src/block/edit-panel/style.scss index 854cfe03f282fe..bb22417472a377 100644 --- a/packages/block-library/src/block/edit-panel/style.scss +++ b/packages/block-library/src/block/edit-panel/style.scss @@ -1,4 +1,4 @@ -.editor-block-list__layout .reusable-block-edit-panel { +.block-editor-block-list__layout .reusable-block-edit-panel { align-items: center; background: $light-gray-100; color: $dark-gray-500; @@ -11,10 +11,13 @@ margin: 0 (-$block-padding); padding: $grid-size $block-padding; position: relative; - z-index: z-index(".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. - .editor-block-list__layout & { + .block-editor-block-list__layout & { margin: 0 (-$block-padding); padding: $grid-size $block-padding; } @@ -59,3 +62,13 @@ } } } + +.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/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index d95b07aedb9150..722aad97c9bfcc 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -10,7 +10,7 @@ import { Component, Fragment } from '@wordpress/element'; import { Placeholder, Spinner, Disabled } from '@wordpress/components'; import { withSelect, withDispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; -import { BlockEdit } from '@wordpress/editor'; +import { BlockEdit } from '@wordpress/block-editor'; import { compose } from '@wordpress/compose'; /** @@ -97,7 +97,7 @@ class ReusableBlockEdit extends Component { } render() { - const { isSelected, reusableBlock, block, isFetching, isSaving } = this.props; + const { isSelected, reusableBlock, block, isFetching, isSaving, canUpdateBlock } = this.props; const { isEditing, title, changedAttributes } = this.state; if ( ! reusableBlock && isFetching ) { @@ -130,6 +130,7 @@ class ReusableBlockEdit extends Component { isEditing={ isEditing } title={ title !== null ? title : reusableBlock.title } isSaving={ isSaving && ! reusableBlock.isTemporary } + isEditDisabled={ ! canUpdateBlock } onEdit={ this.startEditing } onChangeTitle={ this.setTitle } onSave={ this.save } @@ -149,8 +150,11 @@ export default compose( [ __experimentalGetReusableBlock: getReusableBlock, __experimentalIsFetchingReusableBlock: isFetchingReusableBlock, __experimentalIsSavingReusableBlock: isSavingReusableBlock, - getBlock, } = select( 'core/editor' ); + const { canUser } = select( 'core' ); + const { + getBlock, + } = select( 'core/block-editor' ); const { ref } = ownProps.attributes; const reusableBlock = getReusableBlock( ref ); @@ -159,15 +163,18 @@ export default compose( [ isFetching: isFetchingReusableBlock( ref ), isSaving: isSavingReusableBlock( ref ), block: reusableBlock ? getBlock( reusableBlock.clientId ) : null, + canUpdateBlock: !! reusableBlock && ! reusableBlock.isTemporary && !! canUser( 'update', 'blocks', ref ), }; } ), withDispatch( ( dispatch, ownProps ) => { const { __experimentalFetchReusableBlocks: fetchReusableBlocks, - updateBlockAttributes, __experimentalUpdateReusableBlockTitle: updateReusableBlockTitle, __experimentalSaveReusableBlock: saveReusableBlock, } = dispatch( 'core/editor' ); + const { + updateBlockAttributes, + } = dispatch( 'core/block-editor' ); const { ref } = ownProps.attributes; return { diff --git a/packages/block-library/src/block/indicator/style.scss b/packages/block-library/src/block/indicator/style.scss index 1f3937ea99cd94..08ebbce4674faf 100644 --- a/packages/block-library/src/block/indicator/style.scss +++ b/packages/block-library/src/block/indicator/style.scss @@ -1,13 +1,12 @@ -.editor-block-list__layout .reusable-block-indicator { +.block-editor-block-list__layout .reusable-block-indicator { background: $white; - border-left: $border-width dashed $light-gray-500; + border: $border-width dashed $light-gray-500; color: $dark-gray-500; - border-bottom: $border-width dashed $light-gray-500; top: -$block-padding; height: 30px; padding: $grid-size-small; position: absolute; - z-index: z-index(".editor-block-list__layout .reusable-block-indicator"); + z-index: z-index(".block-editor-block-list__layout .reusable-block-indicator"); width: 30px; right: -$block-padding; } diff --git a/packages/block-library/src/button/edit.js b/packages/block-library/src/button/edit.js index 860d6a1fb64b6b..1cf0c1e43d049c 100644 --- a/packages/block-library/src/button/edit.js +++ b/packages/block-library/src/button/edit.js @@ -24,7 +24,7 @@ import { InspectorControls, withColors, PanelColorSettings, -} from '@wordpress/editor'; +} from '@wordpress/block-editor'; const { getComputedStyle } = window; diff --git a/packages/block-library/src/button/editor.scss b/packages/block-library/src/button/editor.scss index 1330ff2cdf96f9..713e508bef0553 100644 --- a/packages/block-library/src/button/editor.scss +++ b/packages/block-library/src/button/editor.scss @@ -1,4 +1,4 @@ -.editor-block-list__block[data-type="core/button"] { +.block-editor-block-list__block[data-type="core/button"] { &[data-align="center"] { text-align: center; } @@ -14,26 +14,26 @@ margin-bottom: 0; position: relative; - .editor-rich-text__tinymce.mce-content-body { + [contenteditable] { cursor: text; } // Make placeholder text white unless custom colors or outline versions are chosen. - &:not(.has-text-color):not(.is-style-outline) .editor-rich-text__tinymce[data-is-placeholder-visible="true"] + .editor-rich-text__tinymce { + &:not(.has-text-color):not(.is-style-outline) .block-editor-rich-text__editable[data-is-placeholder-visible="true"] + .block-editor-rich-text__editable { color: $white; } // Increase placeholder opacity to meet contrast ratios. - .editor-rich-text__tinymce[data-is-placeholder-visible="true"] + .editor-rich-text__tinymce { + .block-editor-rich-text__editable[data-is-placeholder-visible="true"] + .block-editor-rich-text__editable { opacity: 0.8; } // Don't let the placeholder text wrap in the variation preview. - .editor-block-preview__content & { + .block-editor-block-preview__content & { max-width: 100%; // Polish the empty placeholder text for the button in variation previews. - .editor-rich-text__tinymce[data-is-placeholder-visible="true"] { + .block-editor-rich-text__editable[data-is-placeholder-visible="true"] { height: auto; } @@ -59,13 +59,13 @@ $blocks-button__link-input-width: 300px + 2px + 2 * $icon-button-size; width: $blocks-button__link-input-width; - .editor-url-input { + .block-editor-url-input { width: auto; } - .editor-url-input__suggestions { + .block-editor-url-input__suggestions { width: $blocks-button__link-input-width - $icon-button-size - $icon-button-size; - z-index: z-index(".block-library-button__inline-link .editor-url-input__suggestions"); + z-index: z-index(".block-library-button__inline-link .block-editor-url-input__suggestions"); } > .dashicon { @@ -76,7 +76,7 @@ color: $dark-gray-100; } - .editor-url-input input[type="text"]::placeholder { + .block-editor-url-input input[type="text"]::placeholder { color: $dark-gray-100; } diff --git a/packages/block-library/src/button/index.js b/packages/block-library/src/button/index.js index 5dddccfa760ca6..d736c954b9982d 100644 --- a/packages/block-library/src/button/index.js +++ b/packages/block-library/src/button/index.js @@ -12,7 +12,7 @@ import { __, _x } from '@wordpress/i18n'; import { RichText, getColorClassName, -} from '@wordpress/editor'; +} from '@wordpress/block-editor'; /** * Internal dependencies @@ -64,12 +64,14 @@ const colorsMigration = ( attributes ) => { export const settings = { title: __( 'Button' ), - description: __( 'Prompt visitors to take action with a custom button.' ), + description: __( 'Prompt visitors to take action with a button-style link.' ), - icon: , + icon: , category: 'layout', + keywords: [ __( 'link' ) ], + attributes: blockAttributes, supports: { @@ -78,7 +80,7 @@ export const settings = { }, styles: [ - { name: 'default', label: _x( 'Rounded', 'block style' ), isDefault: true }, + { name: 'default', label: _x( 'Default', 'block style' ), isDefault: true }, { name: 'outline', label: __( 'Outline' ) }, { name: 'squared', label: _x( 'Squared', 'block style' ) }, ], diff --git a/packages/block-library/src/button/style.scss b/packages/block-library/src/button/style.scss index 2a45e2e574b38f..1519c6a11ddce5 100644 --- a/packages/block-library/src/button/style.scss +++ b/packages/block-library/src/button/style.scss @@ -32,7 +32,8 @@ $blocks-button__height: 56px; &:hover, &:focus, - &:active { + &:active, + &:visited { color: inherit; } } @@ -45,7 +46,7 @@ $blocks-button__height: 56px; color: $dark-gray-700; .wp-block-button__link { - background: transparent; + background-color: transparent; border: 2px solid currentcolor; } } diff --git a/packages/block-library/src/button/test/__snapshots__/index.js.snap b/packages/block-library/src/button/test/__snapshots__/index.js.snap deleted file mode 100644 index e1532798910960..00000000000000 --- a/packages/block-library/src/button/test/__snapshots__/index.js.snap +++ /dev/null @@ -1,42 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`core/button block edit matches snapshot 1`] = ` -
    - -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -
    - -
    -`; diff --git a/packages/block-library/src/button/test/index.js b/packages/block-library/src/button/test/index.js deleted file mode 100644 index ed83ce055e6e2d..00000000000000 --- a/packages/block-library/src/button/test/index.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Internal dependencies - */ -import { name, settings } from '../'; -import { blockEditRender } from '../../test/helpers'; - -describe( 'core/button', () => { - test( 'block edit matches snapshot', () => { - const wrapper = blockEditRender( name, settings ); - - expect( wrapper ).toMatchSnapshot(); - } ); -} ); diff --git a/packages/block-library/src/calendar/edit.js b/packages/block-library/src/calendar/edit.js new file mode 100644 index 00000000000000..f884490ec38c80 --- /dev/null +++ b/packages/block-library/src/calendar/edit.js @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import moment from 'moment'; +import memoize from 'memize'; + +/** + * WordPress dependencies + */ +import { + Disabled, + ServerSideRender, +} from '@wordpress/components'; +import { Component } from '@wordpress/element'; +import { withSelect } from '@wordpress/data'; + +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 ( + + + + ); + } +} + +export default withSelect( ( select ) => { + const { + getEditedPostAttribute, + } = select( 'core/editor' ); + 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/packages/block-library/src/calendar/index.js b/packages/block-library/src/calendar/index.js new file mode 100644 index 00000000000000..da8f02dbe4038b --- /dev/null +++ b/packages/block-library/src/calendar/index.js @@ -0,0 +1,33 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; + +export const name = 'core/calendar'; + +export const settings = { + title: __( 'Calendar' ), + + description: __( 'A calendar of your site’s posts.' ), + + icon: 'calendar', + + category: 'widgets', + + keywords: [ __( 'posts' ), __( 'archive' ) ], + + supports: { + align: true, + }, + + edit, + + save() { + return null; + }, +}; diff --git a/packages/block-library/src/calendar/index.php b/packages/block-library/src/calendar/index.php new file mode 100644 index 00000000000000..8e26626f5f861a --- /dev/null +++ b/packages/block-library/src/calendar/index.php @@ -0,0 +1,75 @@ +%2$s
    ', + 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; +} + +/** + * Registers the `core/calendar` block on server. + */ +function register_block_core_calendar() { + register_block_type( + 'core/calendar', + array( + 'attributes' => array( + 'align' => array( + 'type' => 'string', + ), + '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/packages/block-library/src/calendar/style.scss b/packages/block-library/src/calendar/style.scss new file mode 100644 index 00000000000000..f6fc8f3a5ad7be --- /dev/null +++ b/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: 440; + 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/packages/block-library/src/categories/edit.js b/packages/block-library/src/categories/edit.js index 8b23a832018cb5..faee47015394ae 100644 --- a/packages/block-library/src/categories/edit.js +++ b/packages/block-library/src/categories/edit.js @@ -6,16 +6,12 @@ import { times, unescape } from 'lodash'; /** * WordPress dependencies */ -import { Component, Fragment } from '@wordpress/element'; 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, Fragment } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { withInstanceId, compose } from '@wordpress/compose'; -import { - InspectorControls, - BlockControls, - BlockAlignmentToolbar, -} from '@wordpress/editor'; class CategoriesEdit extends Component { constructor() { @@ -61,8 +57,7 @@ class CategoriesEdit extends Component { } getCategoryListClassName( level ) { - const { className } = this.props; - return `${ className }__list ${ className }__list-level-${ level }`; + return `wp-block-categories__list wp-block-categories__list-level-${ level }`; } renderCategoryName( category ) { @@ -91,9 +86,9 @@ class CategoriesEdit extends Component { return (
  • - { this.renderCategoryName( category ) } + { this.renderCategoryName( category ) } { showPostCounts && - + { ' ' }({ category.count }) } @@ -111,7 +106,8 @@ class CategoriesEdit extends Component { } renderCategoryDropdown() { - const { showHierarchy, instanceId, className } = this.props; + 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 }`; @@ -120,7 +116,7 @@ class CategoriesEdit extends Component { - { categories.map( ( category ) => this.renderCategoryDropdownItem( category, 0 ) ) } @@ -149,8 +145,8 @@ class CategoriesEdit extends Component { } render() { - const { attributes, setAttributes, isRequesting } = this.props; - const { align, displayAsDropdown, showHierarchy, showPostCounts } = attributes; + const { attributes, isRequesting } = this.props; + const { displayAsDropdown, showHierarchy, showPostCounts } = attributes; const inspectorControls = ( @@ -191,15 +187,6 @@ class CategoriesEdit extends Component { return ( { inspectorControls } - - { - setAttributes( { align: nextAlign } ); - } } - controls={ [ 'left', 'center', 'right', 'full' ] } - /> -
    { displayAsDropdown ? @@ -215,7 +202,7 @@ export default compose( withSelect( ( select ) => { const { getEntityRecords } = select( 'core' ); const { isResolving } = select( 'core/data' ); - const query = { per_page: -1 }; + const query = { per_page: -1, hide_empty: true }; return { categories: getEntityRecords( 'taxonomy', 'category', query ), diff --git a/packages/block-library/src/categories/index.js b/packages/block-library/src/categories/index.js index ab9c198eff4615..4c65b50840059a 100644 --- a/packages/block-library/src/categories/index.js +++ b/packages/block-library/src/categories/index.js @@ -21,9 +21,6 @@ export const settings = { category: 'widgets', attributes: { - align: { - type: 'string', - }, displayAsDropdown: { type: 'boolean', default: false, @@ -39,16 +36,11 @@ export const settings = { }, supports: { + align: true, + alignWide: false, html: false, }, - getEditWrapperProps( attributes ) { - const { align } = attributes; - if ( [ 'left', 'center', 'right', 'full' ].includes( align ) ) { - return { 'data-align': align }; - } - }, - edit, save() { diff --git a/packages/block-library/src/classic/edit.js b/packages/block-library/src/classic/edit.js index f0eb37ff30ca9b..49c1ec2340e506 100644 --- a/packages/block-library/src/classic/edit.js +++ b/packages/block-library/src/classic/edit.js @@ -128,7 +128,7 @@ export default class ClassicEdit extends Component { editor.addButton( 'kitchensink', { tooltip: _x( 'More', 'button to expand options' ), icon: 'dashicon dashicons-editor-kitchensink', - onClick: function() { + onClick() { const button = this; const active = ! button.active(); diff --git a/packages/block-library/src/classic/editor.scss b/packages/block-library/src/classic/editor.scss index 1cda8777992fa7..b4427ec2b21ad0 100644 --- a/packages/block-library/src/classic/editor.scss +++ b/packages/block-library/src/classic/editor.scss @@ -1,6 +1,4 @@ .wp-block-freeform.block-library-rich-text__tinymce { - overflow: hidden; - p, li { line-height: $editor-line-height; @@ -109,23 +107,111 @@ .wp-more-tag { width: 96%; - height: 0; + height: 20px; display: block; margin: 15px auto; outline: 0; cursor: default; - border: 2px dashed rgb(186, 186, 186); + background-image: url(/wp-includes/js/tinymce/skins/wordpress/images/more-2x.png); + background-size: 1900px 20px; + background-repeat: no-repeat; + background-position: center; } - /** - * 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; + /* 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 { @@ -177,39 +263,59 @@ } } -.editor-block-list__layout .editor-block-list__block[data-type="core/freeform"] { +div[data-type="core/freeform"] { + .block-editor-block-list__block-edit::before { + transition: border-color 0.1s linear, box-shadow 0.1s linear; + border: $border-width solid $light-gray-500; - // 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; + // Windows High Contrast mode will show this outline. + outline: $border-width solid transparent; } - // Prevent i tags in buttons from picking up theme editor styles. - .mce-btn i { - font-style: normal; + &.is-selected .block-editor-block-list__block-edit::before { + border-color: $light-gray-800; + border-left-color: transparent; } - // Adjust padding to not cause a jump. - .mce-toolbar-grp > div { - padding: 1px 3px; + // Don't show block type label for classic block + &.is-hovered .block-editor-block-list__breadcrumb { + display: none; } - .editor-block-list__block-edit::before { - outline: $border-width solid #e2e4e7; + .editor-block-contextual-toolbar + div { + margin-top: 0; + padding-top: 0; } - // Don't show block type label for classic block - &.is-hovered .editor-block-list__breadcrumb { - display: none; + // Ensure aligned blocks at end are within the selected block. + &.is-selected .block-library-rich-text__tinymce::after { + content: ""; + display: table; + clear: both; } } -div[data-type="core/freeform"] .editor-block-contextual-toolbar + div { - margin-top: 0; - padding-top: 0; +// mce global styles: the toolbars may get appended to +.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 { @@ -219,73 +325,86 @@ div[data-type="core/freeform"] .editor-block-contextual-toolbar + div { 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 $block-padding; + .is-selected & { + border-color: $light-gray-800; + border-left-color: transparent; + } + @include break-small() { padding: 0; } -} -.block-library-classic__toolbar:empty { - height: $block-toolbar-height; - background: #f5f5f5; - border-bottom: $border-width solid #e2e4e7; + &:empty { + height: $block-toolbar-height; + 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; + &::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. -.block-library-classic__toolbar .mce-tinymce-inline, -.block-library-classic__toolbar .mce-tinymce-inline > div, -.block-library-classic__toolbar div.mce-toolbar-grp, -.block-library-classic__toolbar div.mce-toolbar-grp > div, -.block-library-classic__toolbar .mce-menubar, -.block-library-classic__toolbar .mce-menubar > div { - height: auto !important; - width: 100% !important; -} + // 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; + } -.block-library-classic__toolbar .mce-container-body.mce-abs-layout { - overflow: visible; -} + .mce-container-body.mce-abs-layout { + overflow: visible; + } -.block-library-classic__toolbar .mce-menubar, -.block-library-classic__toolbar div.mce-toolbar-grp { - position: static; -} + .mce-menubar, + div.mce-toolbar-grp { + position: static; + } -.block-library-classic__toolbar .mce-toolbar-grp .mce-toolbar:not(:first-child) { - display: none; -} + // Adjust padding to not cause a jump. + .mce-toolbar-grp > div { + padding: 1px 3px; + } -.block-library-classic__toolbar.has-advanced-toolbar .mce-toolbar-grp .mce-toolbar { - display: block; + .mce-toolbar-grp .mce-toolbar:not(:first-child) { + display: none; + } + + &.has-advanced-toolbar .mce-toolbar-grp .mce-toolbar { + display: block; + } } // We don't want the ellipsis to overlap the classic toolbar, which it will due to position sticky. // So we move it to the right, and make room for it. @include break-small() { - .editor-block-list__block[data-type="core/freeform"] { - .editor-block-switcher__no-switcher-icon { + .block-editor-block-list__block[data-type="core/freeform"] { + .block-editor-block-switcher__no-switcher-icon { display: none; } - .editor-block-contextual-toolbar { + .block-editor-block-contextual-toolbar { float: right; - margin-right: $icon-button-size - $block-padding + $border-width; + margin-right: $icon-button-size - $block-padding + ($border-width * 3); transform: translateY(-#{ $block-padding - $border-width }); top: $block-padding; - .editor-block-toolbar { + .block-editor-block-toolbar { border: none; + box-shadow: none; // Match the TinyMCE "mobile" breakpoint buttons alignment. margin-top: 3px; @@ -300,6 +419,10 @@ div[data-type="core/freeform"] .editor-block-contextual-toolbar + div { margin-top: $grid-size-small; margin-bottom: $grid-size-small; } + + .components-toolbar__control.components-button:hover { + background-color: transparent; + } } .components-toolbar { diff --git a/packages/block-library/src/classic/test/__snapshots__/index.js.snap b/packages/block-library/src/classic/test/__snapshots__/index.js.snap deleted file mode 100644 index 690b0d5e30fdd1..00000000000000 --- a/packages/block-library/src/classic/test/__snapshots__/index.js.snap +++ /dev/null @@ -1,15 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`core/freeform block edit matches snapshot 1`] = ` -Array [ -
    , -
    , -] -`; diff --git a/packages/block-library/src/classic/test/index.js b/packages/block-library/src/classic/test/index.js deleted file mode 100644 index 824c835543e7bc..00000000000000 --- a/packages/block-library/src/classic/test/index.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Internal dependencies - */ -import { name, settings } from '../'; -import { blockEditRender } from '../../test/helpers'; - -describe( 'core/freeform', () => { - test( 'block edit matches snapshot', () => { - const wrapper = blockEditRender( name, settings ); - - expect( wrapper ).toMatchSnapshot(); - } ); -} ); diff --git a/packages/block-library/src/code/edit.js b/packages/block-library/src/code/edit.js index b41ba7327f15d9..5aa4b337bd3f12 100644 --- a/packages/block-library/src/code/edit.js +++ b/packages/block-library/src/code/edit.js @@ -6,7 +6,7 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import { PlainText } from '@wordpress/editor'; +import { PlainText } from '@wordpress/block-editor'; export default function CodeEdit( { attributes, setAttributes, className } ) { return ( diff --git a/packages/block-library/src/code/edit.native.js b/packages/block-library/src/code/edit.native.js index d4ee2c8071c691..264814f760dee7 100644 --- a/packages/block-library/src/code/edit.native.js +++ b/packages/block-library/src/code/edit.native.js @@ -11,7 +11,7 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import { PlainText } from '@wordpress/editor'; +import { PlainText } from '@wordpress/block-editor'; /** * Block code style @@ -36,6 +36,7 @@ export default function CodeEdit( props ) { isSelected={ props.isSelected } onFocus={ onFocus } onBlur={ onBlur } + fontFamily={ ( styles.blockCode.fontFamily ) } /> ); diff --git a/packages/block-library/src/code/editor.scss b/packages/block-library/src/code/editor.scss index 1f53d244794e4d..5fb4e6e4682058 100644 --- a/packages/block-library/src/code/editor.scss +++ b/packages/block-library/src/code/editor.scss @@ -1,8 +1,13 @@ -.wp-block-code .editor-plain-text { +.wp-block-code .block-editor-plain-text { font-family: $editor-html-font; - font-size: $text-editor-font-size; 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; } diff --git a/packages/block-library/src/code/test/__snapshots__/index.js.snap b/packages/block-library/src/code/test/__snapshots__/index.js.snap deleted file mode 100644 index 4d44efaa90a744..00000000000000 --- a/packages/block-library/src/code/test/__snapshots__/index.js.snap +++ /dev/null @@ -1,14 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`core/code block edit matches snapshot 1`] = ` -
    -
    "`; +exports[`Using Plugins API Sidebar Should open plugins sidebar using More Menu item and render content 1`] = `"
    Sidebar title plugin
    "`; diff --git a/test/e2e/specs/__snapshots__/templates.test.js.snap b/packages/e2e-tests/specs/plugins/__snapshots__/templates.test.js.snap similarity index 100% rename from test/e2e/specs/__snapshots__/templates.test.js.snap rename to packages/e2e-tests/specs/plugins/__snapshots__/templates.test.js.snap diff --git a/test/e2e/specs/__snapshots__/wp-editor-meta-box.test.js.snap b/packages/e2e-tests/specs/plugins/__snapshots__/wp-editor-meta-box.test.js.snap similarity index 100% rename from test/e2e/specs/__snapshots__/wp-editor-meta-box.test.js.snap rename to packages/e2e-tests/specs/plugins/__snapshots__/wp-editor-meta-box.test.js.snap diff --git a/test/e2e/specs/align-hook.test.js b/packages/e2e-tests/specs/plugins/align-hook.test.js similarity index 90% rename from test/e2e/specs/align-hook.test.js rename to packages/e2e-tests/specs/plugins/align-hook.test.js index 252d5692a13796..aacacbe657e462 100644 --- a/test/e2e/specs/align-hook.test.js +++ b/packages/e2e-tests/specs/plugins/align-hook.test.js @@ -1,15 +1,16 @@ /** - * Internal dependencies + * WordPress dependencies */ import { - newPost, - insertBlock, - getEditedPostContent, - setPostContent, + activatePlugin, + createNewPost, + deactivatePlugin, getAllBlocks, + getEditedPostContent, + insertBlock, selectBlockByClientId, -} from '../support/utils'; -import { activatePlugin, deactivatePlugin } from '../support/plugins'; + setPostContent, +} from '@wordpress/e2e-test-utils'; describe( 'Align Hook Works As Expected', () => { beforeAll( async () => { @@ -17,7 +18,7 @@ describe( 'Align Hook Works As Expected', () => { } ); beforeEach( async () => { - await newPost(); + await createNewPost(); } ); afterAll( async () => { @@ -28,7 +29,7 @@ describe( 'Align Hook Works As Expected', () => { const buttonLabels = await page.evaluate( () => { return Array.from( document.querySelectorAll( - '.editor-block-toolbar button[aria-label^="Align"]' + '.block-editor-block-toolbar button[aria-label^="Align"]' ) ).map( ( button ) => { @@ -53,7 +54,7 @@ describe( 'Align Hook Works As Expected', () => { it( 'Does not apply any alignment by default', async () => { await insertBlock( blockName ); // verify no alignment button is in pressed state - const pressedButtons = await page.$$( '.editor-block-toolbar button[aria-label^="Align"][aria-pressed="true"]' ); + const pressedButtons = await page.$$( '.block-editor-block-toolbar button[aria-label^="Align"][aria-pressed="true"]' ); expect( pressedButtons ).toHaveLength( 0 ); } ); }; @@ -68,7 +69,7 @@ describe( 'Align Hook Works As Expected', () => { const createCorrectlyAppliesAndRemovesAlignmentTest = ( blockName, alignment ) => { it( 'Correctly applies the selected alignment and correctly removes the alignment', async () => { - const BUTTON_SELECTOR = `.editor-block-toolbar button[aria-label="Align ${ alignment }"]`; + const BUTTON_SELECTOR = `.block-editor-block-toolbar button[aria-label="Align ${ alignment }"]`; const BUTTON_PRESSED_SELECTOR = `${ BUTTON_SELECTOR }[aria-pressed="true"]`; // set the specified alignment. await insertBlock( blockName ); @@ -160,7 +161,7 @@ describe( 'Align Hook Works As Expected', () => { describe( 'Block with default align', () => { const BLOCK_NAME = 'Test Default Align'; - const PRESSED_BUTTON_SELECTOR = '.editor-block-toolbar button[aria-label="Align right"][aria-pressed="true"]'; + const PRESSED_BUTTON_SELECTOR = '.block-editor-block-toolbar button[aria-label="Align right"][aria-pressed="true"]'; createShowsTheExpectedButtonsTest( BLOCK_NAME, [ 'Align left', 'Align center', diff --git a/packages/e2e-tests/specs/plugins/allowed-blocks.test.js b/packages/e2e-tests/specs/plugins/allowed-blocks.test.js new file mode 100644 index 00000000000000..9e86e4ad019908 --- /dev/null +++ b/packages/e2e-tests/specs/plugins/allowed-blocks.test.js @@ -0,0 +1,36 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + 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 paragraphBlock = await page.$( `button[aria-label="Paragraph"]` ); + expect( paragraphBlock ).not.toBeNull(); + await paragraphBlock.click(); + + // The gallery block is not available. + await searchForBlock( 'Gallery' ); + const galleryBlock = await page.$( `button[aria-label="Gallery"]` ); + expect( galleryBlock ).toBeNull(); + } ); +} ); diff --git a/test/e2e/specs/annotations.test.js b/packages/e2e-tests/specs/plugins/annotations.test.js similarity index 86% rename from test/e2e/specs/annotations.test.js rename to packages/e2e-tests/specs/plugins/annotations.test.js index 3fa1eefdec9dda..65474777d3db1d 100644 --- a/test/e2e/specs/annotations.test.js +++ b/packages/e2e-tests/specs/plugins/annotations.test.js @@ -1,15 +1,16 @@ /** - * Internal dependencies + * WordPress dependencies */ import { + activatePlugin, clickOnMoreMenuItem, - newPost, -} from '../support/utils'; -import { activatePlugin, deactivatePlugin } from '../support/plugins'; + createNewPost, + deactivatePlugin, +} from '@wordpress/e2e-test-utils'; const clickOnBlockSettingsMenuItem = async ( buttonLabel ) => { - await expect( page ).toClick( '.editor-block-settings-menu__toggle' ); - const itemButton = ( await page.$x( `//*[contains(@class, "editor-block-settings-menu__popover")]//button[contains(text(), '${ buttonLabel }')]` ) )[ 0 ]; + await expect( page ).toClick( '.block-editor-block-settings-menu__toggle' ); + const itemButton = ( await page.$x( `//*[contains(@class, "block-editor-block-settings-menu__popover")]//button[contains(text(), '${ buttonLabel }')]` ) )[ 0 ]; await itemButton.click(); }; @@ -25,7 +26,7 @@ describe( 'Using Plugins API', () => { } ); beforeEach( async () => { - await newPost(); + await createNewPost(); } ); /** @@ -79,7 +80,7 @@ describe( 'Using Plugins API', () => { * @return {Promise} Inner HTML. */ async function getRichTextInnerHTML() { - const htmlContent = await page.$$( '.editor-rich-text__tinymce' ); + const htmlContent = await page.$$( '*[contenteditable]' ); return await page.evaluate( ( el ) => { return el.innerHTML; }, htmlContent[ 0 ] ); @@ -104,7 +105,7 @@ describe( 'Using Plugins API', () => { await clickOnBlockSettingsMenuItem( 'Edit as HTML' ); - const htmlContent = await page.$$( '.editor-block-list__block-html-textarea' ); + const htmlContent = await page.$$( '.block-editor-block-list__block-html-textarea' ); const html = await page.evaluate( ( el ) => { return el.innerHTML; }, htmlContent[ 0 ] ); @@ -123,7 +124,7 @@ describe( 'Using Plugins API', () => { await page.keyboard.type( 'D' ); await removeAnnotations(); - const htmlContent = await page.$$( '.editor-rich-text__tinymce' ); + const htmlContent = await page.$$( '*[contenteditable]' ); const html = await page.evaluate( ( el ) => { return el.innerHTML; }, htmlContent[ 0 ] ); @@ -137,6 +138,8 @@ describe( 'Using Plugins API', () => { await annotateFirstBlock( 1, 2 ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); await page.keyboard.press( 'ArrowLeft' ); await page.keyboard.press( 'ArrowLeft' ); @@ -157,6 +160,7 @@ describe( 'Using Plugins API', () => { 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. diff --git a/test/e2e/specs/block-icons.test.js b/packages/e2e-tests/specs/plugins/block-icons.test.js similarity index 89% rename from test/e2e/specs/block-icons.test.js rename to packages/e2e-tests/specs/plugins/block-icons.test.js index 4042b05a6f7003..3035a6ddf1aeac 100644 --- a/test/e2e/specs/block-icons.test.js +++ b/packages/e2e-tests/specs/plugins/block-icons.test.js @@ -1,18 +1,19 @@ /** - * Internal dependencies + * WordPress dependencies */ import { - pressWithModifier, - newPost, + activatePlugin, + createNewPost, + deactivatePlugin, insertBlock, + pressKeyWithModifier, searchForBlock, -} from '../support/utils'; -import { activatePlugin, deactivatePlugin } from '../support/plugins'; +} from '@wordpress/e2e-test-utils'; -const INSERTER_BUTTON_SELECTOR = '.components-popover__content .editor-block-types-list__item'; -const INSERTER_ICON_WRAPPER_SELECTOR = `${ INSERTER_BUTTON_SELECTOR } .editor-block-types-list__item-icon`; -const INSERTER_ICON_SELECTOR = `${ INSERTER_BUTTON_SELECTOR } .editor-block-icon`; -const INSPECTOR_ICON_SELECTOR = '.edit-post-sidebar .editor-block-icon'; +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 ); @@ -35,8 +36,8 @@ async function getFirstInserterIcon() { } async function selectFirstBlock() { - await pressWithModifier( 'access', 'o' ); - const navButtons = await page.$$( '.editor-block-navigation__item-button' ); + await pressKeyWithModifier( 'access', 'o' ); + const navButtons = await page.$$( '.block-editor-block-navigation__item-button' ); await navButtons[ 0 ].click(); } @@ -58,7 +59,7 @@ describe( 'Correctly Renders Block Icons on Inserter and Inspector', () => { } ); beforeEach( async () => { - await newPost(); + await createNewPost(); } ); afterAll( async () => { diff --git a/test/e2e/specs/container-blocks.test.js b/packages/e2e-tests/specs/plugins/container-blocks.test.js similarity index 83% rename from test/e2e/specs/container-blocks.test.js rename to packages/e2e-tests/specs/plugins/container-blocks.test.js index bafa4415fd77cc..c01eb6eeca9a4d 100644 --- a/test/e2e/specs/container-blocks.test.js +++ b/packages/e2e-tests/specs/plugins/container-blocks.test.js @@ -1,13 +1,14 @@ /** - * Internal dependencies + * WordPress dependencies */ import { - newPost, - insertBlock, - switchToEditor, + activatePlugin, + createNewPost, + deactivatePlugin, getEditedPostContent, -} from '../support/utils'; -import { activatePlugin, deactivatePlugin } from '../support/plugins'; + insertBlock, + switchEditorModeTo, +} from '@wordpress/e2e-test-utils'; describe( 'InnerBlocks Template Sync', () => { beforeAll( async () => { @@ -15,7 +16,7 @@ describe( 'InnerBlocks Template Sync', () => { } ); beforeEach( async () => { - await newPost(); + await createNewPost(); } ); afterAll( async () => { @@ -29,7 +30,8 @@ describe( 'InnerBlocks Template Sync', () => { `; await insertBlock( blockName ); - await switchToEditor( 'Code' ); + await switchEditorModeTo( 'Code' ); + await page.waitForSelector( '.editor-post-text-editor' ); await page.$eval( '.editor-post-text-editor', ( element, _paragraph, _blockSlug ) => { const blockDelimiter = ``; element.value = element.value.replace( blockDelimiter, `${ _paragraph }${ blockDelimiter }` ); @@ -37,7 +39,7 @@ describe( 'InnerBlocks Template Sync', () => { // 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 switchToEditor( 'Visual' ); + await switchEditorModeTo( 'Visual' ); }; it( 'Ensures blocks without locking are kept intact even if they do not match the template ', async () => { @@ -66,7 +68,7 @@ describe( 'Container block without paragraph support', () => { } ); beforeEach( async () => { - await newPost(); + await createNewPost(); } ); afterAll( async () => { @@ -77,10 +79,10 @@ describe( 'Container block without paragraph support', () => { await insertBlock( 'Container without paragraph' ); // Open the specific appender used when there's no paragraph support. - await page.click( '.editor-inner-blocks .block-list-appender .block-list-appender__toggle' ); + await page.click( '.block-editor-inner-blocks .block-list-appender .block-list-appender__toggle' ); // Insert an image block. - await page.click( '.editor-inserter__results button[aria-label="Image"]' ); + await page.click( '.block-editor-inserter__results button[aria-label="Image"]' ); // Check the inserted content. expect( await getEditedPostContent() ).toMatchSnapshot(); diff --git a/packages/e2e-tests/specs/plugins/cpt-locking.test.js b/packages/e2e-tests/specs/plugins/cpt-locking.test.js new file mode 100644 index 00000000000000..2a858b8a5c8499 --- /dev/null +++ b/packages/e2e-tests/specs/plugins/cpt-locking.test.js @@ -0,0 +1,99 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + clickBlockToolbarButton, + createNewPost, + deactivatePlugin, + getEditedPostContent, + insertBlock, +} 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(); + } ); + } ); + + 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/test/e2e/specs/deprecated-node-matcher.test.js b/packages/e2e-tests/specs/plugins/deprecated-node-matcher.test.js similarity index 82% rename from test/e2e/specs/deprecated-node-matcher.test.js rename to packages/e2e-tests/specs/plugins/deprecated-node-matcher.test.js index a93f23f0de8837..21b587a00c28b4 100644 --- a/test/e2e/specs/deprecated-node-matcher.test.js +++ b/packages/e2e-tests/specs/plugins/deprecated-node-matcher.test.js @@ -1,13 +1,14 @@ /** - * Internal dependencies + * WordPress dependencies */ import { - newPost, - insertBlock, + activatePlugin, + createNewPost, + deactivatePlugin, getEditedPostContent, - pressWithModifier, -} from '../support/utils'; -import { activatePlugin, deactivatePlugin } from '../support/plugins'; + insertBlock, + pressKeyWithModifier, +} from '@wordpress/e2e-test-utils'; describe( 'Deprecated Node Matcher', () => { beforeAll( async () => { @@ -15,7 +16,7 @@ describe( 'Deprecated Node Matcher', () => { } ); beforeEach( async () => { - await newPost(); + await createNewPost(); } ); afterAll( async () => { @@ -37,7 +38,7 @@ describe( 'Deprecated Node Matcher', () => { await page.keyboard.down( 'Shift' ); await page.keyboard.press( 'ArrowLeft' ); await page.keyboard.up( 'Shift' ); - await pressWithModifier( 'primary', 'b' ); + await pressKeyWithModifier( 'primary', 'b' ); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); } ); diff --git a/packages/e2e-tests/specs/plugins/format-api.test.js b/packages/e2e-tests/specs/plugins/format-api.test.js new file mode 100644 index 00000000000000..814ace8fd842a5 --- /dev/null +++ b/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/test/e2e/specs/hooks-api.test.js b/packages/e2e-tests/specs/plugins/hooks-api.test.js similarity index 87% rename from test/e2e/specs/hooks-api.test.js rename to packages/e2e-tests/specs/plugins/hooks-api.test.js index 3af80549b511db..7ad6f518c1e699 100644 --- a/test/e2e/specs/hooks-api.test.js +++ b/packages/e2e-tests/specs/plugins/hooks-api.test.js @@ -1,11 +1,12 @@ /** - * Internal dependencies + * WordPress dependencies */ import { + activatePlugin, clickBlockAppender, - newPost, -} from '../support/utils'; -import { activatePlugin, deactivatePlugin } from '../support/plugins'; + createNewPost, + deactivatePlugin, +} from '@wordpress/e2e-test-utils'; describe( 'Using Hooks API', () => { beforeAll( async () => { @@ -17,7 +18,7 @@ describe( 'Using Hooks API', () => { } ); beforeEach( async () => { - await newPost(); + await createNewPost(); } ); it( 'Should contain a reset block button on the sidebar', async () => { diff --git a/packages/e2e-tests/specs/plugins/inner-blocks-allowed-blocks.test.js b/packages/e2e-tests/specs/plugins/inner-blocks-allowed-blocks.test.js new file mode 100644 index 00000000000000..a25f9a81af95c9 --- /dev/null +++ b/packages/e2e-tests/specs/plugins/inner-blocks-allowed-blocks.test.js @@ -0,0 +1,59 @@ +/** + * 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', + ] ); + } ); +} ); diff --git a/packages/e2e-tests/specs/plugins/meta-attribute-block.test.js b/packages/e2e-tests/specs/plugins/meta-attribute-block.test.js new file mode 100644 index 00000000000000..45c82561599797 --- /dev/null +++ b/packages/e2e-tests/specs/plugins/meta-attribute-block.test.js @@ -0,0 +1,53 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin, + createNewPost, + deactivatePlugin, + getEditedPostContent, + insertBlock, + saveDraft, +} 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( 'Meta Value' ); + 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 inputs.forEach( async ( input ) => { + // Clicking the input selects the block, + // and selecting the block enables the sync data mode + // as otherwise the asynchronous rerendering of unselected blocks + // may cause the input to have not yet been updated for the other blocks + await input.click(); + expect( await input.getProperty( 'value' ) ).toBe( 'Meta Value' ); + } ); + } ); +} ); diff --git a/packages/e2e-tests/specs/plugins/meta-boxes.test.js b/packages/e2e-tests/specs/plugins/meta-boxes.test.js new file mode 100644 index 00000000000000..0a21dfe20d9d72 --- /dev/null +++ b/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/test/e2e/specs/plugins-api.test.js b/packages/e2e-tests/specs/plugins/plugins-api.test.js similarity index 94% rename from test/e2e/specs/plugins-api.test.js rename to packages/e2e-tests/specs/plugins/plugins-api.test.js index d149cf492a5a33..d264bb736ed082 100644 --- a/test/e2e/specs/plugins-api.test.js +++ b/packages/e2e-tests/specs/plugins/plugins-api.test.js @@ -1,15 +1,16 @@ /** - * Internal dependencies + * WordPress dependencies */ import { + activatePlugin, clickBlockAppender, clickOnMoreMenuItem, + createNewPost, + deactivatePlugin, openDocumentSettingsSidebar, - newPost, openPublishPanel, publishPost, -} from '../support/utils'; -import { activatePlugin, deactivatePlugin } from '../support/plugins'; +} from '@wordpress/e2e-test-utils'; describe( 'Using Plugins API', () => { beforeAll( async () => { @@ -21,7 +22,7 @@ describe( 'Using Plugins API', () => { } ); beforeEach( async () => { - await newPost(); + await createNewPost(); } ); describe( 'Post Status Info', () => { diff --git a/test/e2e/specs/templates.test.js b/packages/e2e-tests/specs/plugins/templates.test.js similarity index 85% rename from test/e2e/specs/templates.test.js rename to packages/e2e-tests/specs/plugins/templates.test.js index 4d8d16e9ce9513..749842554fe664 100644 --- a/test/e2e/specs/templates.test.js +++ b/packages/e2e-tests/specs/plugins/templates.test.js @@ -1,17 +1,18 @@ /** - * Internal dependencies + * WordPress dependencies */ import { - newPost, + activatePlugin, + clickBlockAppender, + createNewPost, + deactivatePlugin, getEditedPostContent, + pressKeyWithModifier, saveDraft, - pressWithModifier, - visitAdmin, - clickBlockAppender, - switchToAdminUser, - switchToTestUser, -} from '../support/utils'; -import { activatePlugin, deactivatePlugin } from '../support/plugins'; + switchUserToAdmin, + switchUserToTest, + visitAdminPage, +} from '@wordpress/e2e-test-utils'; describe( 'templates', () => { describe( 'Using a CPT with a predefined template', () => { @@ -20,7 +21,7 @@ describe( 'templates', () => { } ); beforeEach( async () => { - await newPost( { postType: 'book' } ); + await createNewPost( { postType: 'book' } ); } ); afterAll( async () => { @@ -48,7 +49,7 @@ describe( 'templates', () => { // re-added after saving and reloading the editor. await page.type( '.editor-post-title__input', 'My Empty Book' ); await page.keyboard.press( 'ArrowDown' ); - await pressWithModifier( 'primary', 'A' ); + await pressKeyWithModifier( 'primary', 'A' ); await page.keyboard.press( 'Backspace' ); await saveDraft(); await page.reload(); @@ -62,14 +63,14 @@ describe( 'templates', () => { async function setPostFormat( format ) { // To set the post format, we need to be the admin user. - await switchToAdminUser(); - await visitAdmin( 'options-writing.php' ); + await switchUserToAdmin(); + await visitAdminPage( 'options-writing.php' ); await page.select( '#default_post_format', format ); await Promise.all( [ page.waitForNavigation(), page.click( '#submit' ), ] ); - await switchToTestUser(); + await switchUserToTest(); } beforeAll( async () => { @@ -82,13 +83,13 @@ describe( 'templates', () => { } ); it( 'should populate new post with default block for format', async () => { - await newPost(); + await createNewPost(); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); it( 'should not populate edited post with default block for format', async () => { - await newPost(); + await createNewPost(); // Remove the default block template to verify that it's not // re-added after saving and reloading the editor. @@ -105,11 +106,11 @@ describe( 'templates', () => { 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 switchToAdminUser(); - await newPost( { postType: 'page' } ); + await switchUserToAdmin(); + await createNewPost( { postType: 'page' } ); expect( await getEditedPostContent() ).toMatchSnapshot(); - await switchToTestUser(); + await switchUserToTest(); } ); } ); } ); diff --git a/test/e2e/specs/wp-editor-meta-box.test.js b/packages/e2e-tests/specs/plugins/wp-editor-meta-box.test.js similarity index 83% rename from test/e2e/specs/wp-editor-meta-box.test.js rename to packages/e2e-tests/specs/plugins/wp-editor-meta-box.test.js index 496057bb55c1cf..c99da39b7cfe00 100644 --- a/test/e2e/specs/wp-editor-meta-box.test.js +++ b/packages/e2e-tests/specs/plugins/wp-editor-meta-box.test.js @@ -1,13 +1,17 @@ /** - * Internal dependencies + * WordPress dependencies */ -import { newPost, publishPost } from '../support/utils'; -import { activatePlugin, deactivatePlugin } from '../support/plugins'; +import { + activatePlugin, + createNewPost, + deactivatePlugin, + publishPost, +} from '@wordpress/e2e-test-utils'; describe( 'WP Editor Meta Boxes', () => { beforeAll( async () => { await activatePlugin( 'gutenberg-test-plugin-wp-editor-meta-box' ); - await newPost(); + await createNewPost(); } ); afterAll( async () => { diff --git a/test/e2e/specs/popovers.test.js b/packages/e2e-tests/specs/popovers.test.js similarity index 83% rename from test/e2e/specs/popovers.test.js rename to packages/e2e-tests/specs/popovers.test.js index 202c8d7cb93621..5a1e17817dabff 100644 --- a/test/e2e/specs/popovers.test.js +++ b/packages/e2e-tests/specs/popovers.test.js @@ -1,11 +1,11 @@ /** - * Internal dependencies + * WordPress dependencies */ -import { newPost } from '../support/utils'; +import { createNewPost } from '@wordpress/e2e-test-utils'; describe( 'popovers', () => { beforeEach( async () => { - await newPost(); + await createNewPost(); } ); describe( 'dropdown', () => { diff --git a/test/e2e/specs/post-visibility.test.js b/packages/e2e-tests/specs/post-visibility.test.js similarity index 78% rename from test/e2e/specs/post-visibility.test.js rename to packages/e2e-tests/specs/post-visibility.test.js index 392fd3f9c6efdf..0c93dcdef0f896 100644 --- a/test/e2e/specs/post-visibility.test.js +++ b/packages/e2e-tests/specs/post-visibility.test.js @@ -1,18 +1,18 @@ /** - * Internal dependencies + * WordPress dependencies */ import { - setViewport, - newPost, + setBrowserViewport, + createNewPost, openDocumentSettingsSidebar, -} from '../support/utils'; +} from '@wordpress/e2e-test-utils'; describe( 'Post visibility', () => { [ 'large', 'small' ].forEach( ( viewport ) => { it( `can be changed when the viewport is ${ viewport }`, async () => { - await setViewport( viewport ); + await setBrowserViewport( viewport ); - await newPost(); + await createNewPost(); await openDocumentSettingsSidebar(); diff --git a/test/e2e/specs/preferences.test.js b/packages/e2e-tests/specs/preferences.test.js similarity index 93% rename from test/e2e/specs/preferences.test.js rename to packages/e2e-tests/specs/preferences.test.js index d405009969fa20..24cf347d96501e 100644 --- a/test/e2e/specs/preferences.test.js +++ b/packages/e2e-tests/specs/preferences.test.js @@ -1,11 +1,11 @@ /** - * Internal dependencies + * WordPress dependencies */ -import { newPost } from '../support/utils'; +import { createNewPost } from '@wordpress/e2e-test-utils'; describe( 'preferences', () => { beforeAll( async () => { - await newPost(); + await createNewPost(); } ); /** diff --git a/test/e2e/specs/preview.test.js b/packages/e2e-tests/specs/preview.test.js similarity index 96% rename from test/e2e/specs/preview.test.js rename to packages/e2e-tests/specs/preview.test.js index 7cd1f57daf73f9..8488ffe1ec1e31 100644 --- a/test/e2e/specs/preview.test.js +++ b/packages/e2e-tests/specs/preview.test.js @@ -5,18 +5,18 @@ import { last } from 'lodash'; import { parse } from 'url'; /** - * Internal dependencies + * WordPress dependencies */ import { - newPost, - getUrl, + createNewPost, + createURL, publishPost, saveDraft, -} from '../support/utils'; +} from '@wordpress/e2e-test-utils'; describe( 'Preview', () => { beforeEach( async () => { - await newPost(); + await createNewPost(); } ); async function openPreviewPage( editorPage ) { @@ -72,7 +72,7 @@ describe( 'Preview', () => { return window.location.search.match( /[\?&]post=(\d+)/ ); } ) ).jsonValue(); - const expectedPreviewURL = getUrl( '', `?p=${ postId }&preview=true` ); + const expectedPreviewURL = createURL( '', `?p=${ postId }&preview=true` ); expect( previewPage.url() ).toBe( expectedPreviewURL ); // Title in preview should match input. diff --git a/test/e2e/specs/publish-button.test.js b/packages/e2e-tests/specs/publish-button.test.js similarity index 93% rename from test/e2e/specs/publish-button.test.js rename to packages/e2e-tests/specs/publish-button.test.js index 0db024ce7ac81f..ecdd6bf76b0005 100644 --- a/test/e2e/specs/publish-button.test.js +++ b/packages/e2e-tests/specs/publish-button.test.js @@ -1,14 +1,17 @@ +/** + * WordPress dependencies + */ import { arePrePublishChecksEnabled, disablePrePublishChecks, enablePrePublishChecks, - newPost, -} from '../support/utils'; + createNewPost, +} from '@wordpress/e2e-test-utils'; describe( 'PostPublishButton', () => { let werePrePublishChecksEnabled; beforeEach( async () => { - await newPost( ); + await createNewPost( ); werePrePublishChecksEnabled = await arePrePublishChecksEnabled(); if ( werePrePublishChecksEnabled ) { await disablePrePublishChecks(); diff --git a/test/e2e/specs/publish-panel.test.js b/packages/e2e-tests/specs/publish-panel.test.js similarity index 90% rename from test/e2e/specs/publish-panel.test.js rename to packages/e2e-tests/specs/publish-panel.test.js index 27842682577c78..c28a3837d74438 100644 --- a/test/e2e/specs/publish-panel.test.js +++ b/packages/e2e-tests/specs/publish-panel.test.js @@ -1,17 +1,20 @@ +/** + * WordPress dependencies + */ import { arePrePublishChecksEnabled, disablePrePublishChecks, enablePrePublishChecks, - newPost, + createNewPost, openPublishPanel, - pressWithModifier, + pressKeyWithModifier, publishPost, -} from '../support/utils'; +} from '@wordpress/e2e-test-utils'; describe( 'PostPublishPanel', () => { let werePrePublishChecksEnabled; beforeEach( async () => { - await newPost( ); + await createNewPost( ); werePrePublishChecksEnabled = await arePrePublishChecksEnabled(); if ( ! werePrePublishChecksEnabled ) { await enablePrePublishChecks(); @@ -52,7 +55,7 @@ describe( 'PostPublishPanel', () => { it( 'should retain focus within the panel', async () => { await page.type( '.editor-post-title__input', 'E2E Test Post' ); await openPublishPanel(); - await pressWithModifier( 'shift', 'Tab' ); + await pressKeyWithModifier( 'shift', 'Tab' ); const focusedElementClassList = await page.$eval( ':focus', ( focusedElement ) => { return Object.values( focusedElement.classList ); diff --git a/test/e2e/specs/publishing.test.js b/packages/e2e-tests/specs/publishing.test.js similarity index 88% rename from test/e2e/specs/publishing.test.js rename to packages/e2e-tests/specs/publishing.test.js index eca85bf6f01ed8..b484428e4fcc50 100644 --- a/test/e2e/specs/publishing.test.js +++ b/packages/e2e-tests/specs/publishing.test.js @@ -1,22 +1,22 @@ /** - * Internal dependencies + * WordPress dependencies */ import { - newPost, + createNewPost, publishPost, - publishPostWithoutPrePublishChecks, + publishPostWithPrePublishChecksDisabled, enablePrePublishChecks, disablePrePublishChecks, arePrePublishChecksEnabled, - setViewport, -} from '../support/utils'; + setBrowserViewport, +} from '@wordpress/e2e-test-utils'; describe( 'Publishing', () => { [ 'post', 'page' ].forEach( ( postType ) => { let werePrePublishChecksEnabled; describe( `a ${ postType }`, () => { beforeEach( async () => { - await newPost( postType ); + await createNewPost( postType ); werePrePublishChecksEnabled = await arePrePublishChecksEnabled(); if ( ! werePrePublishChecksEnabled ) { await enablePrePublishChecks(); @@ -50,7 +50,7 @@ describe( 'Publishing', () => { let werePrePublishChecksEnabled; describe( `a ${ postType } with pre-publish checks disabled`, () => { beforeEach( async () => { - await newPost( postType ); + await createNewPost( postType ); werePrePublishChecksEnabled = await arePrePublishChecksEnabled(); if ( werePrePublishChecksEnabled ) { await disablePrePublishChecks(); @@ -70,7 +70,7 @@ describe( 'Publishing', () => { expect( await page.$( '.editor-post-publish-panel__toggle' ) ).toBeNull(); expect( await page.$( '.editor-post-publish-button' ) ).not.toBeNull(); - await publishPostWithoutPrePublishChecks(); + await publishPostWithPrePublishChecksDisabled(); // The post-publishing panel should have been not shown. expect( await page.$( '.editor-post-publish-panel' ) ).toBeNull(); @@ -82,16 +82,16 @@ describe( 'Publishing', () => { let werePrePublishChecksEnabled; describe( `a ${ postType } in small viewports`, () => { beforeEach( async () => { - await newPost( postType ); + await createNewPost( postType ); werePrePublishChecksEnabled = await arePrePublishChecksEnabled(); if ( werePrePublishChecksEnabled ) { await disablePrePublishChecks(); } - await setViewport( 'small' ); + await setBrowserViewport( 'small' ); } ); afterEach( async () => { - await setViewport( 'large' ); + await setBrowserViewport( 'large' ); if ( werePrePublishChecksEnabled ) { await enablePrePublishChecks(); } diff --git a/test/e2e/specs/reusable-blocks.test.js b/packages/e2e-tests/specs/reusable-blocks.test.js similarity index 83% rename from test/e2e/specs/reusable-blocks.test.js rename to packages/e2e-tests/specs/reusable-blocks.test.js index 3dc3d32afa6c16..73d7b9117bf43b 100644 --- a/test/e2e/specs/reusable-blocks.test.js +++ b/packages/e2e-tests/specs/reusable-blocks.test.js @@ -1,13 +1,14 @@ /** - * Internal dependencies + * WordPress dependencies */ import { insertBlock, - newPost, - pressWithModifier, + createNewPost, + clickBlockToolbarButton, + pressKeyWithModifier, searchForBlock, getEditedPostContent, -} from '../support/utils'; +} from '@wordpress/e2e-test-utils'; function waitForAndAcceptDialog() { return new Promise( ( resolve ) => { @@ -17,7 +18,7 @@ function waitForAndAcceptDialog() { describe( 'Reusable Blocks', () => { beforeAll( async () => { - await newPost(); + await createNewPost(); } ); beforeEach( async () => { @@ -34,13 +35,8 @@ describe( 'Reusable Blocks', () => { await insertBlock( 'Paragraph' ); await page.keyboard.type( 'Hello there!' ); - // Trigger isTyping = false - await page.mouse.move( 200, 300, { steps: 10 } ); - await page.mouse.move( 250, 350, { steps: 10 } ); + await clickBlockToolbarButton( 'More options' ); - // Convert block to a reusable block - await page.waitForSelector( 'button[aria-label="More options"]' ); - await page.click( 'button[aria-label="More options"]' ); const convertButton = await page.waitForXPath( '//button[text()="Add to Reusable Blocks"]' ); await convertButton.click(); @@ -65,7 +61,7 @@ describe( 'Reusable Blocks', () => { await page.waitForXPath( '//button[text()="Edit"]' ); // Check that we have a reusable block on the page - const block = await page.$( '.editor-block-list__block[data-type="core/block"]' ); + const block = await page.$( '.block-editor-block-list__block[data-type="core/block"]' ); expect( block ).not.toBeNull(); // Check that its title is displayed @@ -81,13 +77,8 @@ describe( 'Reusable Blocks', () => { await insertBlock( 'Paragraph' ); await page.keyboard.type( 'Hello there!' ); - // Trigger isTyping = false - await page.mouse.move( 200, 300, { steps: 10 } ); - await page.mouse.move( 250, 350, { steps: 10 } ); + await clickBlockToolbarButton( 'More options' ); - // Convert block to a reusable block - await page.waitForSelector( 'button[aria-label="More options"]' ); - await page.click( 'button[aria-label="More options"]' ); const convertButton = await page.waitForXPath( '//button[text()="Add to Reusable Blocks"]' ); await convertButton.click(); @@ -104,7 +95,7 @@ describe( 'Reusable Blocks', () => { await page.waitForXPath( '//button[text()="Edit"]' ); // Check that we have a reusable block on the page - const block = await page.$( '.editor-block-list__block[data-type="core/block"]' ); + const block = await page.$( '.block-editor-block-list__block[data-type="core/block"]' ); expect( block ).not.toBeNull(); // Check that it is untitled @@ -141,7 +132,7 @@ describe( 'Reusable Blocks', () => { await page.waitForXPath( '//button[text()="Edit"]' ); // Check that we have a reusable block on the page - const block = await page.$( '.editor-block-list__block[data-type="core/block"]' ); + const block = await page.$( '.block-editor-block-list__block[data-type="core/block"]' ); expect( block ).not.toBeNull(); // Check that its title is displayed @@ -153,7 +144,7 @@ describe( 'Reusable Blocks', () => { // Check that its content is up to date const text = await page.$eval( - '.editor-block-list__block[data-type="core/block"] .editor-rich-text', + '.block-editor-block-list__block[data-type="core/block"] .block-editor-rich-text', ( element ) => element.innerText ); expect( text ).toMatch( 'Oh! Hello there!' ); @@ -171,12 +162,12 @@ describe( 'Reusable Blocks', () => { await convertButton.click(); // Check that we have a paragraph block on the page - const block = await page.$( '.editor-block-list__block[data-type="core/paragraph"]' ); + 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( - '.editor-block-list__block[data-type="core/paragraph"] .editor-rich-text', + '.block-editor-block-list__block[data-type="core/paragraph"] .block-editor-rich-text', ( element ) => element.innerText ); expect( text ).toMatch( 'Oh! Hello there!' ); @@ -204,13 +195,13 @@ describe( 'Reusable Blocks', () => { // Check that we couldn't find it const items = await page.$$( - '.editor-block-types-list__item[aria-label="Surprised greeting block"]' + '.block-editor-block-types-list__item[aria-label="Surprised greeting block"]' ); expect( items ).toHaveLength( 0 ); } ); it( 'can be created from multiselection', async () => { - await newPost(); + await createNewPost(); // Insert a Two paragraphs block await insertBlock( 'Paragraph' ); @@ -219,12 +210,8 @@ describe( 'Reusable Blocks', () => { await page.keyboard.type( 'Second paragraph' ); // Select all the blocks - await pressWithModifier( 'primary', 'a' ); - await pressWithModifier( 'primary', 'a' ); - - // Trigger isTyping = false - await page.mouse.move( 200, 300, { steps: 10 } ); - await page.mouse.move( 250, 350, { steps: 10 } ); + await pressKeyWithModifier( 'primary', 'a' ); + await pressKeyWithModifier( 'primary', 'a' ); // Convert block to a reusable block await page.waitForSelector( 'button[aria-label="More options"]' ); @@ -253,7 +240,7 @@ describe( 'Reusable Blocks', () => { await page.waitForXPath( '//button[text()="Edit"]' ); // Check that we have a reusable block on the page - const block = await page.$( '.editor-block-list__block[data-type="core/block"]' ); + const block = await page.$( '.block-editor-block-list__block[data-type="core/block"]' ); expect( block ).not.toBeNull(); // Check that its title is displayed diff --git a/packages/e2e-tests/specs/rich-text.test.js b/packages/e2e-tests/specs/rich-text.test.js new file mode 100644 index 00000000000000..5153032953899e --- /dev/null +++ b/packages/e2e-tests/specs/rich-text.test.js @@ -0,0 +1,183 @@ +/** + * 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 access shortcut', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'test' ); + await pressKeyWithModifier( 'primary', 'a' ); + await pressKeyWithModifier( 'access', 'd' ); + + 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 return focus when pressing formatting button', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'Some ' ); + await page.keyboard.press( 'Escape' ); + await page.click( '[aria-label="Bold"]' ); + await page.keyboard.type( 'bold' ); + await page.keyboard.press( 'Escape' ); + 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 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(); + } ); +} ); diff --git a/test/e2e/specs/shortcut-help.test.js b/packages/e2e-tests/specs/shortcut-help.test.js similarity index 85% rename from test/e2e/specs/shortcut-help.test.js rename to packages/e2e-tests/specs/shortcut-help.test.js index ea69639fe36229..03c53949b436dd 100644 --- a/test/e2e/specs/shortcut-help.test.js +++ b/packages/e2e-tests/specs/shortcut-help.test.js @@ -1,16 +1,16 @@ /** - * Internal dependencies + * WordPress dependencies */ import { - newPost, + createNewPost, clickOnMoreMenuItem, clickOnCloseModalButton, - pressWithModifier, -} from '../support/utils'; + pressKeyWithModifier, +} from '@wordpress/e2e-test-utils'; describe( 'keyboard shortcut help modal', () => { beforeAll( async () => { - await newPost(); + await createNewPost(); } ); it( 'displays the shortcut help modal when opened using the menu item in the more menu', async () => { @@ -26,13 +26,13 @@ describe( 'keyboard shortcut help modal', () => { } ); it( 'displays the shortcut help modal when opened using the shortcut key (access+h)', async () => { - await pressWithModifier( 'access', 'h' ); + 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 pressWithModifier( 'access', 'h' ); + await pressKeyWithModifier( 'access', 'h' ); const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help' ); expect( shortcutHelpModalElements ).toHaveLength( 0 ); } ); diff --git a/packages/e2e-tests/specs/sidebar-permalink-panel.test.js b/packages/e2e-tests/specs/sidebar-permalink-panel.test.js new file mode 100644 index 00000000000000..16de106f570e67 --- /dev/null +++ b/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/test/e2e/specs/sidebar.test.js b/packages/e2e-tests/specs/sidebar.test.js similarity index 84% rename from test/e2e/specs/sidebar.test.js rename to packages/e2e-tests/specs/sidebar.test.js index 3b5c9b92716673..def5d0f5011413 100644 --- a/test/e2e/specs/sidebar.test.js +++ b/packages/e2e-tests/specs/sidebar.test.js @@ -1,14 +1,14 @@ /** - * Internal dependencies + * WordPress dependencies */ import { findSidebarPanelWithTitle, - newPost, + createNewPost, observeFocusLoss, openDocumentSettingsSidebar, - pressWithModifier, - setViewport, -} from '../support/utils'; + 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'; @@ -20,8 +20,8 @@ describe( 'Sidebar', () => { } ); it( 'should have sidebar visible at the start with document sidebar active on desktop', async () => { - await setViewport( 'large' ); - await newPost(); + await setBrowserViewport( 'large' ); + await createNewPost(); const { nodesCount, content, height, width } = await page.$$eval( ACTIVE_SIDEBAR_TAB_SELECTOR, ( nodes ) => { const firstNode = nodes[ 0 ]; return { @@ -44,20 +44,20 @@ describe( 'Sidebar', () => { } ); it( 'should have the sidebar closed by default on mobile', async () => { - await setViewport( 'small' ); - await newPost(); + await setBrowserViewport( 'small' ); + await createNewPost(); const sidebar = await page.$( SIDEBAR_SELECTOR ); expect( sidebar ).toBeNull(); } ); it( 'should close the sidebar when resizing from desktop to mobile', async () => { - await setViewport( 'large' ); - await newPost(); + await setBrowserViewport( 'large' ); + await createNewPost(); const sidebars = await page.$$( SIDEBAR_SELECTOR ); expect( sidebars ).toHaveLength( 1 ); - await setViewport( 'small' ); + await setBrowserViewport( 'small' ); const sidebarsMobile = await page.$$( SIDEBAR_SELECTOR ); // sidebar should be closed when resizing to mobile. @@ -65,27 +65,27 @@ describe( 'Sidebar', () => { } ); it( 'should reopen sidebar the sidebar when resizing from mobile to desktop if the sidebar was closed automatically', async () => { - await setViewport( 'large' ); - await newPost(); - await setViewport( 'small' ); + await setBrowserViewport( 'large' ); + await createNewPost(); + await setBrowserViewport( 'small' ); const sidebarsMobile = await page.$$( SIDEBAR_SELECTOR ); expect( sidebarsMobile ).toHaveLength( 0 ); - await setViewport( 'large' ); + await setBrowserViewport( 'large' ); const sidebarsDesktop = await page.$$( SIDEBAR_SELECTOR ); expect( sidebarsDesktop ).toHaveLength( 1 ); } ); it( 'should preserve tab order while changing active tab', async () => { - await newPost(); + await createNewPost(); // Region navigate to Sidebar. - await pressWithModifier( 'ctrl', '`' ); - await pressWithModifier( 'ctrl', '`' ); - await pressWithModifier( 'ctrl', '`' ); - await pressWithModifier( 'ctrl', '`' ); + 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' ); @@ -106,7 +106,7 @@ describe( 'Sidebar', () => { } ); it( 'should be possible to programmatically remove Document Settings panels', async () => { - await newPost(); + await createNewPost(); await openDocumentSettingsSidebar(); diff --git a/test/e2e/specs/splitting-merging.test.js b/packages/e2e-tests/specs/splitting-merging.test.js similarity index 87% rename from test/e2e/specs/splitting-merging.test.js rename to packages/e2e-tests/specs/splitting-merging.test.js index f6456dfa1e3429..8df6e104645ae4 100644 --- a/test/e2e/specs/splitting-merging.test.js +++ b/packages/e2e-tests/specs/splitting-merging.test.js @@ -1,17 +1,18 @@ /** - * Internal dependencies + * WordPress dependencies */ import { - newPost, + createNewPost, insertBlock, + isInDefaultBlock, getEditedPostContent, - pressTimes, - pressWithModifier, -} from '../support/utils'; + pressKeyTimes, + pressKeyWithModifier, +} from '@wordpress/e2e-test-utils'; describe( 'splitting and merging blocks', () => { beforeEach( async () => { - await newPost(); + await createNewPost(); } ); it( 'should split and merge paragraph blocks using Enter and Backspace', async () => { @@ -21,7 +22,7 @@ describe( 'splitting and merging blocks', () => { // Move caret between 'First' and 'Second' and press Enter to split // paragraph blocks - await pressTimes( 'ArrowLeft', 6 ); + await pressKeyTimes( 'ArrowLeft', 6 ); await page.keyboard.press( 'Enter' ); // Assert that there are now two paragraph blocks with correct content @@ -34,16 +35,16 @@ describe( 'splitting and merging blocks', () => { await page.keyboard.type( 'Between' ); expect( await getEditedPostContent() ).toMatchSnapshot(); - await pressTimes( 'Backspace', 7 ); // Delete "Between" + 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 pressTimes( 'ArrowRight', 5 ); + await pressKeyTimes( 'ArrowRight', 5 ); await page.keyboard.up( 'Shift' ); - await pressWithModifier( 'primary', 'b' ); + await pressKeyWithModifier( 'primary', 'b' ); // Collapse selection, still within inline boundary. await page.keyboard.press( 'ArrowRight' ); await page.keyboard.press( 'Enter' ); @@ -56,13 +57,13 @@ describe( 'splitting and merging blocks', () => { // Regression Test: Caret should reset to end of inline boundary when // backspacing to delete second paragraph. await insertBlock( 'Paragraph' ); - await pressWithModifier( 'primary', 'b' ); + 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 pressTimes( 'Backspace', 4 ); + await pressKeyTimes( 'Backspace', 4 ); await page.keyboard.type( 'Bar' ); expect( await getEditedPostContent() ).toMatchSnapshot(); @@ -107,7 +108,7 @@ describe( 'splitting and merging blocks', () => { // Select text. await page.keyboard.down( 'Shift' ); - await pressTimes( 'ArrowLeft', 3 ); + await pressKeyTimes( 'ArrowLeft', 3 ); await page.keyboard.up( 'Shift' ); // Delete selection. @@ -126,9 +127,9 @@ describe( 'splitting and merging blocks', () => { // The regression appeared to only affect paragraphs created while // within an inline boundary. await page.keyboard.down( 'Shift' ); - await pressTimes( 'ArrowLeft', 3 ); + await pressKeyTimes( 'ArrowLeft', 3 ); await page.keyboard.up( 'Shift' ); - await pressWithModifier( 'primary', 'b' ); + await pressKeyWithModifier( 'primary', 'b' ); await page.keyboard.press( 'ArrowRight' ); await page.keyboard.press( 'Enter' ); await page.keyboard.press( 'Enter' ); @@ -139,7 +140,7 @@ describe( 'splitting and merging blocks', () => { } ); it( 'should forward delete from an empty paragraph', async () => { - // Regression test: Bogus nodes in a TinyMCE container can interfere + // Regression test: Bogus nodes in a RichText container can interfere // with isHorizontalEdge detection, preventing forward deletion. // // See: https://github.com/WordPress/gutenberg/issues/8731 @@ -193,21 +194,12 @@ describe( 'splitting and merging blocks', () => { await page.keyboard.press( 'Backspace' ); // There is a default block: - expect( await page.$$( '.editor-block-list__block' ) ).toHaveLength( 1 ); + 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: - const isInDefaultBlock = await page.evaluate( () => { - const activeBlockName = document.activeElement - .closest( '[data-type]' ) - .getAttribute( 'data-type' ); - const defaultBlockName = window.wp.blocks.getDefaultBlockName(); - - return activeBlockName === defaultBlockName; - } ); - - expect( isInDefaultBlock ).toBe( true ); + expect( await isInDefaultBlock() ).toBe( true ); } ); } ); diff --git a/packages/e2e-tests/specs/style-variation.test.js b/packages/e2e-tests/specs/style-variation.test.js new file mode 100644 index 00000000000000..7b073fe7b3f26d --- /dev/null +++ b/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/packages/e2e-tests/specs/taxonomies.test.js b/packages/e2e-tests/specs/taxonomies.test.js new file mode 100644 index 00000000000000..2cd8e7665eda47 --- /dev/null +++ b/packages/e2e-tests/specs/taxonomies.test.js @@ -0,0 +1,176 @@ +/** + * 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-input:checked' + ) ).map( ( node ) => { + return node.parentElement.querySelector( + 'label' + ).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-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-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' ); + } ); + + it( '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/packages/e2e-tests/specs/undo.test.js b/packages/e2e-tests/specs/undo.test.js new file mode 100644 index 00000000000000..71caccf09b3c3f --- /dev/null +++ b/packages/e2e-tests/specs/undo.test.js @@ -0,0 +1,66 @@ +/** + * WordPress dependencies + */ +import { + clickBlockAppender, + getEditedPostContent, + createNewPost, + pressKeyWithModifier, +} 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' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await pressKeyWithModifier( 'primary', 'z' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + 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' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await pressKeyWithModifier( 'primary', 'z' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'Should undo to expected level intervals', async () => { + await clickBlockAppender(); + + await page.keyboard.type( 'This' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'is' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'test' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await pressKeyWithModifier( 'primary', 'z' ); // Undo 3rd paragraph text. + await pressKeyWithModifier( 'primary', 'z' ); // Undo 3rd block. + await pressKeyWithModifier( 'primary', 'z' ); // Undo 2nd paragraph text. + await pressKeyWithModifier( 'primary', 'z' ); // Undo 2nd block. + await pressKeyWithModifier( 'primary', 'z' ); // Undo 1st paragraph text. + 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(); + } ); +} ); diff --git a/packages/e2e-tests/specs/writing-flow.test.js b/packages/e2e-tests/specs/writing-flow.test.js new file mode 100644 index 00000000000000..62ece158016405 --- /dev/null +++ b/packages/e2e-tests/specs/writing-flow.test.js @@ -0,0 +1,301 @@ +/** + * WordPress dependencies + */ +import { + clickBlockAppender, + getEditedPostContent, + createNewPost, + pressKeyTimes, + pressKeyWithModifier, + insertBlock, +} from '@wordpress/e2e-test-utils'; + +describe( 'adding blocks', () => { + beforeEach( async () => { + await createNewPost(); + } ); + + it( 'Should navigate inner blocks with arrow keys', async () => { + 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.keyboard.type( 'First col' ); + + // Arrow down should navigate through layouts in columns block (to + // its default appender). Two key presses are required since the first + // will land user on the Column wrapper block. + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.type( 'Second col' ); + + // 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( 'Second 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( 'First 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