From 52a3375f9f1d356453aea2af1203fc11d8949805 Mon Sep 17 00:00:00 2001 From: Anton Timmermans Date: Fri, 9 Nov 2018 18:44:51 +0100 Subject: [PATCH] Annotation API (#7718) * Implement annotations Integrate Annotation API with Format API Keep annotations when applying formatting Change parameters for toTree after rebasing on master Fix unit test Add files after `npm run docs:build` Only re-render one block when adding annotation Do this by optimizing the annotations state to keep track of which annotations belongs to which block. This information can then be used in the selector to return the same reference if the annotations for a block haven't changed. Add unit tests for annotations reducer Also remove MOVE_ANNOTATION action. Rename block to blockClientId Remove block ID from RichText documentation It is not relevant at that level. Change block annotation to use selector property Build docs Fix low hanging fruit Add annotation support for block attributes Rename blockAttribute to richTextIdentifier Simplify annotations to only `start` and `end` Revert changes to to-tree Add annotation support to lists and headings Build docs Move the applyAnnotations call into formatToValue Refactor applyAnnotation to only loop once Add basic e2e test for annotation API Disallow invalid annotation ranges in state Drop general block annotation className Add assertions for annotations behavior Add selector to retrieve currently selected text Move removeAnnotations to valueToFormat Move applyAnnotations and removeAnnotations They don't use `this`, so it is only fair. Fix failing tests Build docs Move annotations state to @wordpress/annotations * Mark Annotation API as experimental * Move annotation format to annotation package * Move annotation application to annotation package * Remove getCurrentRichTextSelection selector * Remove richText selection remnants * Remove annotations from rich-text docs * Remove duplicate props * Move block annotation to annotations package * Properly define dependencies * Add _experimental flag to e2e tests * Remove wp-annotations as a dependency of wp-editor * Fix spacing * Annotations: Add missing uuid dependency * Annotations: Fix DocBlock inconsistency * Annotations: Add missing Lodash dependency * Annotations: Fix up a few more DocBlocks * Annotations: Add a basic description for package * Docs: Include Annotations document in root manifest * Update dependencies --- docs/data/README.md | 1 + docs/data/data-core-annotations.md | 84 +++++++++ docs/extensibility/annotations.md | 55 ++++++ docs/manifest.json | 18 ++ docs/root-manifest.json | 6 + docs/tool/config.js | 5 + lib/client-assets.php | 7 + package-lock.json | 29 ++- package.json | 3 +- packages/annotations/.npmrc | 1 + packages/annotations/CHANGELOG.md | 5 + packages/annotations/README.md | 15 ++ packages/annotations/package.json | 35 ++++ packages/annotations/src/block/index.js | 25 +++ packages/annotations/src/format/annotation.js | 82 +++++++++ packages/annotations/src/format/index.js | 15 ++ packages/annotations/src/index.js | 7 + packages/annotations/src/store/actions.js | 72 ++++++++ packages/annotations/src/store/index.js | 24 +++ packages/annotations/src/store/reducer.js | 109 ++++++++++++ packages/annotations/src/store/selectors.js | 65 +++++++ .../annotations/src/store/test/reducer.js | 166 ++++++++++++++++++ packages/blocks/package.json | 2 +- packages/components/package.json | 2 +- .../editor/src/components/block-list/block.js | 3 +- .../editor/src/components/rich-text/index.js | 8 +- .../__snapshots__/plugins-api.test.js.snap | 2 +- test/e2e/specs/plugins-api.test.js | 40 +++++ test/e2e/test-plugins/plugins-api.php | 1 + test/e2e/test-plugins/plugins-api/sidebar.js | 20 +++ webpack.config.js | 1 + 31 files changed, 894 insertions(+), 14 deletions(-) create mode 100644 docs/data/data-core-annotations.md create mode 100644 docs/extensibility/annotations.md create mode 100644 packages/annotations/.npmrc create mode 100644 packages/annotations/CHANGELOG.md create mode 100644 packages/annotations/README.md create mode 100644 packages/annotations/package.json create mode 100644 packages/annotations/src/block/index.js create mode 100644 packages/annotations/src/format/annotation.js create mode 100644 packages/annotations/src/format/index.js create mode 100644 packages/annotations/src/index.js create mode 100644 packages/annotations/src/store/actions.js create mode 100644 packages/annotations/src/store/index.js create mode 100644 packages/annotations/src/store/reducer.js create mode 100644 packages/annotations/src/store/selectors.js create mode 100644 packages/annotations/src/store/test/reducer.js diff --git a/docs/data/README.md b/docs/data/README.md index 1f99bcb017f5b9..ac44230651976e 100644 --- a/docs/data/README.md +++ b/docs/data/README.md @@ -1,6 +1,7 @@ # Data Module Reference - [**core**: WordPress Core Data](../../docs/data/data-core.md) + - [**core/annotations**: Annotations](../../docs/data/data-core-annotations.md) - [**core/blocks**: Block Types Data](../../docs/data/data-core-blocks.md) - [**core/editor**: The Editor’s Data](../../docs/data/data-core-editor.md) - [**core/edit-post**: The Editor’s UI Data](../../docs/data/data-core-edit-post.md) diff --git a/docs/data/data-core-annotations.md b/docs/data/data-core-annotations.md new file mode 100644 index 00000000000000..f4f7d8cb5ff072 --- /dev/null +++ b/docs/data/data-core-annotations.md @@ -0,0 +1,84 @@ +# **core/annotations**: Annotations + +## Selectors + +### __experimentalGetAnnotationsForBlock + +Returns the annotations for a specific client ID. + +*Parameters* + + * state: Editor state. + * clientId: The ID of the block to get the annotations for. + +### __experimentalGetAnnotationsForRichText + +Returns the annotations that apply to the given RichText instance. + +Both a blockClientId and a richTextIdentifier are required. This is because +a block might have multiple `RichText` components. This does mean that every +block needs to implement annotations itself. + +*Parameters* + + * state: Editor state. + * blockClientId: The client ID for the block. + * richTextIdentifier: Unique identifier that identifies the given RichText. + +*Returns* + +All the annotations relevant for the `RichText`. + +### __experimentalGetAnnotations + +Returns all annotations in the editor state. + +*Parameters* + + * state: Editor state. + +*Returns* + +All annotations currently applied. + +## Actions + +### __experimentalAddAnnotation + +Adds an annotation to a block. + +The `block` attribute refers to a block ID that needs to be annotated. +`isBlockAnnotation` controls whether or not the annotation is a block +annotation. The `source` is the source of the annotation, this will be used +to identity groups of annotations. + +The `range` property is only relevant if the selector is 'range'. + +*Parameters* + + * annotation: The annotation to add. + * blockClientId: The blockClientId to add the annotation to. + * richTextIdentifier: Identifier for the RichText instance the annotation applies to. + * range: The range at which to apply this annotation. + * range.start: The offset where the annotation should start. + * range.end: The offset where the annotation should end. + * string: [selector="range"] The way to apply this annotation. + * string: [source="default"] The source that added the annotation. + * string: [id=uuid()] The ID the annotation should have. + Generates a UUID by default. + +### __experimentalRemoveAnnotation + +Removes an annotation with a specific ID. + +*Parameters* + + * annotationId: The annotation to remove. + +### __experimentalRemoveAnnotationsBySource + +Removes all annotations of a specific source. + +*Parameters* + + * source: The source to remove. \ No newline at end of file diff --git a/docs/extensibility/annotations.md b/docs/extensibility/annotations.md new file mode 100644 index 00000000000000..70df5b61e5d187 --- /dev/null +++ b/docs/extensibility/annotations.md @@ -0,0 +1,55 @@ +# Annotations + +**Note: This API is experimental, that means it is subject to non-backward compatible changes or removal in any future version.** + +Annotations are a way to highlight a specific piece in a Gutenberg post. Examples of this include commenting on a piece of text and spellchecking. Both can use the annotations API to mark a piece of text. + +## API + +To see the API for yourself the easiest way is to have a block that is at least 200 characters long without formatting and putting the following in the console: + +```js +wp.data.dispatch( 'core/annotations' ).addAnnotation( { + source: "my-annotations-plugin", + blockClientId: wp.data.select( 'core/editor' ).getBlockOrder()[0], + richTextIdentifier: "content", + range: { + start: 50, + end: 100, + }, +} ); +``` + +The start and the end of the range should be calculated based only on the text of the relevant `RichText`. For example, in the following HTML position 0 will refer to the position before the capital S: + +```html +Strong text +``` + +To help with determining the correct positions, the `wp.richText.create` method can be used. This will split a piece of HTML into text and formats. + +All available properties can be found in the API documentation of the `addAnnotation` action. + +## Block annotation + +It is also possible to annotate a block completely. In that case just provide the `selector` property and set it to `block`. The default `selector` is `range`, which can be used for text annotation. + +```js +wp.data.dispatch( 'core/annotations' ).addAnnotation( { + source: "my-annotations-plugin", + blockClientId: wp.data.select( 'core/editor' ).getBlockOrder()[0], + selector: "block", +} ); +``` + +This doesn't provide any styling out of the box, so you have to provide some CSS to make sure your annotation is shown: + +```css +.is-annotated-by-my-annotations-plugin { + outline: 1px solid black; +} +``` + +## Text annotation + +The text annotation is controlled by the `start` and `end` properties. Simple `start` and `end` properties don't work for HTML, so these properties are assumed to be offsets within the `rich-text` internal structure. For simplicity you can think about this as if all HTML would be stripped out and then you calculate the `start` and the `end` of the annotation. diff --git a/docs/manifest.json b/docs/manifest.json index efab957c872c6c..075d922f2cad93 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -89,6 +89,12 @@ "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/extensibility/autocomplete.md", "parent": "extensibility" }, + { + "title": "Annotations", + "slug": "annotations", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/extensibility/annotations.md", + "parent": "extensibility" + }, { "title": "Design", "slug": "design", @@ -257,6 +263,12 @@ "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/a11y/README.md", "parent": "packages" }, + { + "title": "@wordpress/annotations", + "slug": "packages-annotations", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/annotations/README.md", + "parent": "packages" + }, { "title": "@wordpress/api-fetch", "slug": "packages-api-fetch", @@ -929,6 +941,12 @@ "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/data/data-core.md", "parent": "data" }, + { + "title": "Annotations", + "slug": "data-core-annotations", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/data/data-core-annotations.md", + "parent": "data" + }, { "title": "Block Types Data", "slug": "data-core-blocks", diff --git a/docs/root-manifest.json b/docs/root-manifest.json index 759792a857b733..1202ab14eacb4a 100644 --- a/docs/root-manifest.json +++ b/docs/root-manifest.json @@ -89,6 +89,12 @@ "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/extensibility/autocomplete.md", "parent": "extensibility" }, + { + "title": "Annotations", + "slug": "annotations", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/extensibility/annotations.md", + "parent": "extensibility" + }, { "title": "Design", "slug": "design", diff --git a/docs/tool/config.js b/docs/tool/config.js index b7b0fa3ef4ad66..6758ac592a72f7 100644 --- a/docs/tool/config.js +++ b/docs/tool/config.js @@ -15,6 +15,11 @@ module.exports = { selectors: [ path.resolve( root, 'packages/core-data/src/selectors.js' ) ], actions: [ path.resolve( root, 'packages/core-data/src/actions.js' ) ], }, + 'core/annotations': { + title: 'Annotations', + selectors: [ path.resolve( root, 'packages/annotations/src/store/selectors.js' ) ], + actions: [ path.resolve( root, 'packages/annotations/src/store/actions.js' ) ], + }, 'core/blocks': { title: 'Block Types Data', selectors: [ path.resolve( root, 'packages/blocks/src/store/selectors.js' ) ], diff --git a/lib/client-assets.php b/lib/client-assets.php index ed95afda111d19..298e1cd0f447ce 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -316,6 +316,13 @@ function gutenberg_register_scripts_and_styles() { ) ) ); + gutenberg_override_script( + 'wp-annotations', + gutenberg_url( 'build/annotations/index.js' ), + array( 'wp-polyfill', 'wp-data', 'wp-rich-text', 'wp-hooks', 'wp-i18n' ), + filemtime( gutenberg_dir_path() . 'build/annotations/index.js' ), + true + ); gutenberg_override_script( 'wp-core-data', gutenberg_url( 'build/core-data/index.js' ), diff --git a/package-lock.json b/package-lock.json index 03591075a66ebf..21595b51e232fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2247,6 +2247,25 @@ "@wordpress/dom-ready": "file:packages/dom-ready" } }, + "@wordpress/annotations": { + "version": "file:packages/annotations", + "requires": { + "@babel/runtime": "^7.0.0", + "@wordpress/data": "file:packages/data", + "@wordpress/hooks": "file:packages/hooks", + "@wordpress/i18n": "file:packages/i18n", + "@wordpress/rich-text": "file:packages/rich-text", + "lodash": "^4.17.10", + "rememo": "^3.0.0", + "uuid": "^3.3.2" + }, + "dependencies": { + "uuid": { + "version": "3.3.2", + "bundled": true + } + } + }, "@wordpress/api-fetch": { "version": "file:packages/api-fetch", "requires": { @@ -2355,7 +2374,7 @@ "showdown": "^1.8.6", "simple-html-tokenizer": "^0.4.1", "tinycolor2": "^1.4.1", - "uuid": "^3.1.0" + "uuid": "^3.3.2" } }, "@wordpress/browserslist-config": { @@ -2390,7 +2409,7 @@ "react-dates": "^17.1.1", "rememo": "^3.0.0", "tinycolor2": "^1.4.1", - "uuid": "^3.1.0" + "uuid": "^3.3.2" } }, "@wordpress/compose": { @@ -21184,9 +21203,9 @@ "dev": true }, "uuid": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.1.0.tgz", - "integrity": "sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g==" + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" }, "v8-compile-cache": { "version": "1.1.2", diff --git a/package.json b/package.json index ffc1552fbb0763..c13873b929e950 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@wordpress/a11y": "file:packages/a11y", + "@wordpress/annotations": "file:packages/annotations", "@wordpress/api-fetch": "file:packages/api-fetch", "@wordpress/autop": "file:packages/autop", "@wordpress/blob": "file:packages/blob", @@ -106,7 +107,7 @@ "stylelint": "9.5.0", "stylelint-config-wordpress": "13.1.0", "symlink-or-copy": "1.2.0", - "uuid": "3.1.0", + "uuid": "3.3.2", "webpack": "4.8.3", "webpack-bundle-analyzer": "3.0.2", "webpack-cli": "2.1.3", diff --git a/packages/annotations/.npmrc b/packages/annotations/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/annotations/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/annotations/CHANGELOG.md b/packages/annotations/CHANGELOG.md new file mode 100644 index 00000000000000..95745945dd46e2 --- /dev/null +++ b/packages/annotations/CHANGELOG.md @@ -0,0 +1,5 @@ +## 1.0.0 (unreleased) + +### New Features + +- Implement annotations API in the editor. diff --git a/packages/annotations/README.md b/packages/annotations/README.md new file mode 100644 index 00000000000000..a1585de3106cb1 --- /dev/null +++ b/packages/annotations/README.md @@ -0,0 +1,15 @@ +# Annotations + +Annotate content in the Gutenberg editor. + +## Installation + +Install the module + +```bash +npm install @wordpress/annotations --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Usage diff --git a/packages/annotations/package.json b/packages/annotations/package.json new file mode 100644 index 00000000000000..b68ce8f7efa25e --- /dev/null +++ b/packages/annotations/package.json @@ -0,0 +1,35 @@ +{ + "name": "@wordpress/annotations", + "version": "1.0.0-beta1", + "description": "Annotate content in the Gutenberg editor.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "annotations" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/annotations/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git" + }, + "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/data": "file:../data", + "@wordpress/hooks": "file:../hooks", + "@wordpress/i18n": "file:../i18n", + "@wordpress/rich-text": "file:../rich-text", + "lodash": "^4.17.10", + "rememo": "^3.0.0", + "uuid": "^3.3.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/annotations/src/block/index.js b/packages/annotations/src/block/index.js new file mode 100644 index 00000000000000..5095fc473d67e6 --- /dev/null +++ b/packages/annotations/src/block/index.js @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; +import { withSelect } from '@wordpress/data'; + +/** + * Adds annotation className to the block-list-block component. + * + * @param {Object} OriginalComponent The original BlockListBlock component. + * @return {Object} The enhanced component. + */ +const addAnnotationClassName = ( OriginalComponent ) => { + return withSelect( ( select, { clientId } ) => { + const annotations = select( 'core/annotations' ).__experimentalGetAnnotationsForBlock( clientId ); + + return { + className: annotations.map( ( annotation ) => { + return 'is-annotated-by-' + annotation.source; + } ), + }; + } )( OriginalComponent ); +}; + +addFilter( 'editor.BlockListBlock', 'core/annotations', addAnnotationClassName ); diff --git a/packages/annotations/src/format/annotation.js b/packages/annotations/src/format/annotation.js new file mode 100644 index 00000000000000..b052de27f335fd --- /dev/null +++ b/packages/annotations/src/format/annotation.js @@ -0,0 +1,82 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +const name = 'core/annotation'; + +/** + * WordPress dependencies + */ +import { applyFormat, removeFormat } from '@wordpress/rich-text'; + +/** + * Applies given annotations to the given record. + * + * @param {Object} record The record to apply annotations to. + * @param {Array} annotations The annotation to apply. + * @return {Object} A record with the annotations applied. + */ +export function applyAnnotations( record, annotations = [] ) { + annotations.forEach( ( annotation ) => { + let { start, end } = annotation; + + if ( start > record.text.length ) { + start = record.text.length; + } + + if ( end > record.text.length ) { + end = record.text.length; + } + + const className = 'annotation-text-' + annotation.source; + + record = applyFormat( + record, + { type: 'core/annotation', attributes: { className } }, + start, + end + ); + } ); + + return record; +} + +/** + * Removes annotations from the given record. + * + * @param {Object} record Record to remove annotations from. + * @return {Object} The cleaned record. + */ +export function removeAnnotations( record ) { + return removeFormat( record, 'core/annotation', 0, record.text.length ); +} + +export const annotation = { + name, + title: __( 'Annotation' ), + tagName: 'mark', + className: 'annotation-text', + attributes: { + className: 'class', + }, + edit() { + return null; + }, + __experimentalGetPropsForEditableTreePreparation( select, { richTextIdentifier, blockClientId } ) { + return { + annotations: select( 'core/annotations' ).__experimentalGetAnnotationsForRichText( blockClientId, richTextIdentifier ), + }; + }, + __experimentalCreatePrepareEditableTree( props ) { + return ( formats, text ) => { + if ( props.annotations.length === 0 ) { + return formats; + } + + let record = { formats, text }; + record = applyAnnotations( record, props.annotations ); + return record.formats; + }; + }, +}; diff --git a/packages/annotations/src/format/index.js b/packages/annotations/src/format/index.js new file mode 100644 index 00000000000000..1dccbbd5012a0c --- /dev/null +++ b/packages/annotations/src/format/index.js @@ -0,0 +1,15 @@ +/** + * WordPress dependencies + */ +import { + registerFormatType, +} from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import { annotation } from './annotation'; + +const { name, ...settings } = annotation; + +registerFormatType( name, settings ); diff --git a/packages/annotations/src/index.js b/packages/annotations/src/index.js new file mode 100644 index 00000000000000..ce64106bf903cf --- /dev/null +++ b/packages/annotations/src/index.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import './store'; +import './format'; +import './block'; + diff --git a/packages/annotations/src/store/actions.js b/packages/annotations/src/store/actions.js new file mode 100644 index 00000000000000..73f8c9e1fe381c --- /dev/null +++ b/packages/annotations/src/store/actions.js @@ -0,0 +1,72 @@ +/** + * External dependencies + */ +import uuid from 'uuid/v4'; + +/** + * Adds an annotation to a block. + * + * The `block` attribute refers to a block ID that needs to be annotated. + * `isBlockAnnotation` controls whether or not the annotation is a block + * annotation. The `source` is the source of the annotation, this will be used + * to identity groups of annotations. + * + * The `range` property is only relevant if the selector is 'range'. + * + * @param {Object} annotation The annotation to add. + * @param {string} blockClientId The blockClientId to add the annotation to. + * @param {string} richTextIdentifier Identifier for the RichText instance the annotation applies to. + * @param {Object} range The range at which to apply this annotation. + * @param {number} range.start The offset where the annotation should start. + * @param {number} range.end The offset where the annotation should end. + * @param {string} [selector="range"] The way to apply this annotation. + * @param {string} [source="default"] The source that added the annotation. + * @param {string} [id=uuid()] The ID the annotation should have. + * Generates a UUID by default. + * + * @return {Object} Action object. + */ +export function __experimentalAddAnnotation( { blockClientId, richTextIdentifier = null, range = null, selector = 'range', source = 'default', id = uuid() } ) { + const action = { + type: 'ANNOTATION_ADD', + id, + blockClientId, + richTextIdentifier, + source, + selector, + }; + + if ( selector === 'range' ) { + action.range = range; + } + + return action; +} + +/** + * Removes an annotation with a specific ID. + * + * @param {string} annotationId The annotation to remove. + * + * @return {Object} Action object. + */ +export function __experimentalRemoveAnnotation( annotationId ) { + return { + type: 'ANNOTATION_REMOVE', + annotationId, + }; +} + +/** + * Removes all annotations of a specific source. + * + * @param {string} source The source to remove. + * + * @return {Object} Action object. + */ +export function __experimentalRemoveAnnotationsBySource( source ) { + return { + type: 'ANNOTATION_REMOVE_SOURCE', + source, + }; +} diff --git a/packages/annotations/src/store/index.js b/packages/annotations/src/store/index.js new file mode 100644 index 00000000000000..917a342ad9f49d --- /dev/null +++ b/packages/annotations/src/store/index.js @@ -0,0 +1,24 @@ +/** + * WordPress Dependencies + */ +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as selectors from './selectors'; +import * as actions from './actions'; + +/** + * Module Constants + */ +const MODULE_KEY = 'core/annotations'; + +const store = registerStore( MODULE_KEY, { + reducer, + selectors, + actions, +} ); + +export default store; diff --git a/packages/annotations/src/store/reducer.js b/packages/annotations/src/store/reducer.js new file mode 100644 index 00000000000000..cb14165a5d6bdd --- /dev/null +++ b/packages/annotations/src/store/reducer.js @@ -0,0 +1,109 @@ +/** + * External dependencies + */ +import { isNumber, mapValues } from 'lodash'; + +/** + * Filters an array based on the predicate, but keeps the reference the same if + * the array hasn't changed. + * + * @param {Array} collection The collection to filter. + * @param {Function} predicate Function that determines if the item should stay + * in the array. + * @return {Array} Filtered array. + */ +function filterWithReference( collection, predicate ) { + const filteredCollection = collection.filter( predicate ); + + return collection.length === filteredCollection.length ? collection : filteredCollection; +} + +/** + * Verifies whether the given annotations is a valid annotation. + * + * @param {Object} annotation The annotation to verify. + * @return {boolean} Whether the given annotation is valid. + */ +function isValidAnnotationRange( annotation ) { + return isNumber( annotation.start ) && + isNumber( annotation.end ) && + annotation.start <= annotation.end; +} + +/** + * Reducer managing annotations. + * + * @param {Array} state The annotations currently shown in the editor. + * @param {Object} action Dispatched action. + * + * @return {Array} Updated state. + */ +export function annotations( state = { all: [], byBlockClientId: {} }, action ) { + switch ( action.type ) { + case 'ANNOTATION_ADD': + const blockClientId = action.blockClientId; + const newAnnotation = { + id: action.id, + blockClientId, + richTextIdentifier: action.richTextIdentifier, + source: action.source, + selector: action.selector, + range: action.range, + }; + + if ( newAnnotation.selector === 'range' && ! isValidAnnotationRange( newAnnotation.range ) ) { + return state; + } + + const previousAnnotationsForBlock = state.byBlockClientId[ blockClientId ] || []; + + return { + all: [ + ...state.all, + newAnnotation, + ], + byBlockClientId: { + ...state.byBlockClientId, + [ blockClientId ]: [ ...previousAnnotationsForBlock, action.id ], + }, + }; + + case 'ANNOTATION_REMOVE': + return { + all: state.all.filter( ( annotation ) => annotation.id !== action.annotationId ), + + // We use filterWithReference to not refresh the reference if a block still has + // the same annotations. + byBlockClientId: mapValues( state.byBlockClientId, ( annotationForBlock ) => { + return filterWithReference( annotationForBlock, ( annotationId ) => { + return annotationId !== action.annotationId; + } ); + } ), + }; + + case 'ANNOTATION_REMOVE_SOURCE': + const idsToRemove = []; + + const allAnnotations = state.all.filter( ( annotation ) => { + if ( annotation.source === action.source ) { + idsToRemove.push( annotation.id ); + return false; + } + + return true; + } ); + + return { + all: allAnnotations, + byBlockClientId: mapValues( state.byBlockClientId, ( annotationForBlock ) => { + return filterWithReference( annotationForBlock, ( annotationId ) => { + return ! idsToRemove.includes( annotationId ); + } ); + } ), + }; + } + + return state; +} + +export default annotations; diff --git a/packages/annotations/src/store/selectors.js b/packages/annotations/src/store/selectors.js new file mode 100644 index 00000000000000..659b83e83e30d1 --- /dev/null +++ b/packages/annotations/src/store/selectors.js @@ -0,0 +1,65 @@ +/** + * External dependencies + */ +import createSelector from 'rememo'; + +/** + * Returns the annotations for a specific client ID. + * + * @param {Object} state Editor state. + * @param {string} clientId The ID of the block to get the annotations for. + * + * @return {Array} The annotations applicable to this block. + */ +export const __experimentalGetAnnotationsForBlock = createSelector( + ( state, blockClientId ) => { + return state.all.filter( ( annotation ) => { + return annotation.selector === 'block' && annotation.blockClientId === blockClientId; + } ); + }, + ( state, blockClientId ) => [ + state.byBlockClientId[ blockClientId ], + ] +); + +/** + * Returns the annotations that apply to the given RichText instance. + * + * Both a blockClientId and a richTextIdentifier are required. This is because + * a block might have multiple `RichText` components. This does mean that every + * block needs to implement annotations itself. + * + * @param {Object} state Editor state. + * @param {string} blockClientId The client ID for the block. + * @param {string} richTextIdentifier Unique identifier that identifies the given RichText. + * @return {Array} All the annotations relevant for the `RichText`. + */ +export const __experimentalGetAnnotationsForRichText = createSelector( + ( state, blockClientId, richTextIdentifier ) => { + return state.all.filter( ( annotation ) => { + return annotation.selector === 'range' && + annotation.blockClientId === blockClientId && + richTextIdentifier === annotation.richTextIdentifier; + } ).map( ( annotation ) => { + const { range, ...other } = annotation; + + return { + ...range, + ...other, + }; + } ); + }, + ( state, blockClientId ) => [ + state.byBlockClientId[ blockClientId ], + ] +); + +/** + * Returns all annotations in the editor state. + * + * @param {Object} state Editor state. + * @return {Array} All annotations currently applied. + */ +export function __experimentalGetAnnotations( state ) { + return state.all; +} diff --git a/packages/annotations/src/store/test/reducer.js b/packages/annotations/src/store/test/reducer.js new file mode 100644 index 00000000000000..a1dba8db8c8ac4 --- /dev/null +++ b/packages/annotations/src/store/test/reducer.js @@ -0,0 +1,166 @@ +/** + * Internal dependencies + */ +import { annotations } from '../reducer'; + +describe( 'annotations', () => { + const initialState = { all: [], byBlockClientId: {} }; + + it( 'returns all annotations and annotation IDs per block', () => { + const state = annotations( undefined, {} ); + + expect( state ).toEqual( { all: [], byBlockClientId: {} } ); + } ); + + it( 'returns a state with an annotation that has been added', () => { + const state = annotations( undefined, { + type: 'ANNOTATION_ADD', + id: 'annotationId', + blockClientId: 'blockClientId', + richTextIdentifier: 'identifier', + source: 'default', + selector: 'block', + } ); + + expect( state ).toEqual( { + all: [ + { + id: 'annotationId', + blockClientId: 'blockClientId', + richTextIdentifier: 'identifier', + source: 'default', + selector: 'block', + }, + ], + byBlockClientId: { + blockClientId: [ 'annotationId' ], + }, + } ); + } ); + + it( 'allows an annotation to be removed', () => { + const state = annotations( { + all: [ + { + id: 'annotationId', + blockClientId: 'blockClientId', + richTextIdentifier: 'identifier', + source: 'default', + selector: 'block', + }, + ], + byBlockClientId: { + blockClientId: [ 'annotationId' ], + }, + }, { + type: 'ANNOTATION_REMOVE', + annotationId: 'annotationId', + } ); + + expect( state ).toEqual( { all: [], byBlockClientId: { blockClientId: [] } } ); + } ); + + it( 'allows an annotation to be removed by its source', () => { + const annotation1 = { + id: 'annotationId', + blockClientId: 'blockClientId', + richTextIdentifier: 'identifier', + source: 'default', + selector: 'block', + }; + const annotation2 = { + id: 'annotationId2', + blockClientId: 'blockClientId2', + richTextIdentifier: 'identifier2', + source: 'other-source', + selector: 'block', + }; + const state = annotations( { + all: [ + annotation1, + annotation2, + ], + byBlockClientId: { + blockClientId: [ 'annotationId' ], + blockClientId2: [ 'annotationId2' ], + }, + }, { + type: 'ANNOTATION_REMOVE_SOURCE', + source: 'default', + } ); + + expect( state ).toEqual( { + all: [ annotation2 ], + byBlockClientId: { + blockClientId: [], + blockClientId2: [ 'annotationId2' ], + }, + } ); + } ); + + it( 'allows a range selector', () => { + const state = annotations( undefined, { + type: 'ANNOTATION_ADD', + id: 'annotationId', + blockClientId: 'blockClientId', + richTextIdentifier: 'identifier', + source: 'default', + selector: 'range', + range: { + start: 0, + end: 100, + }, + } ); + + expect( state ).toEqual( { + all: [ + { + id: 'annotationId', + blockClientId: 'blockClientId', + richTextIdentifier: 'identifier', + source: 'default', + selector: 'range', + range: { + start: 0, + end: 100, + }, + }, + ], + byBlockClientId: { + blockClientId: [ 'annotationId' ], + }, + } ); + } ); + + it( 'rejects invalid annotations', () => { + let state = annotations( undefined, { + type: 'ANNOTATION_ADD', + source: 'default', + selector: 'range', + range: { + start: 5, + end: 4, + }, + } ); + state = annotations( state, { + type: 'ANNOTATION_ADD', + source: 'default', + selector: 'range', + range: { + start: 'not a number', + end: 100, + }, + } ); + state = annotations( state, { + type: 'ANNOTATION_ADD', + source: 'default', + selector: 'range', + range: { + start: 100, + end: 'not a number', + }, + } ); + + expect( state ).toEqual( initialState ); + } ); +} ); diff --git a/packages/blocks/package.json b/packages/blocks/package.json index a3b64afc28c9e0..593f0c261a188b 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -38,7 +38,7 @@ "showdown": "^1.8.6", "simple-html-tokenizer": "^0.4.1", "tinycolor2": "^1.4.1", - "uuid": "^3.1.0" + "uuid": "^3.3.2" }, "devDependencies": { "deep-freeze": "^0.0.1" diff --git a/packages/components/package.json b/packages/components/package.json index ff67b8ea8f9a78..2d6c70ab312b23 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -45,7 +45,7 @@ "react-dates": "^17.1.1", "rememo": "^3.0.0", "tinycolor2": "^1.4.1", - "uuid": "^3.1.0" + "uuid": "^3.3.2" }, "devDependencies": { "@wordpress/token-list": "file:../token-list", diff --git a/packages/editor/src/components/block-list/block.js b/packages/editor/src/components/block-list/block.js index cf505245b9f5a1..15c19c28d186a2 100644 --- a/packages/editor/src/components/block-list/block.js +++ b/packages/editor/src/components/block-list/block.js @@ -398,6 +398,7 @@ export class BlockListBlock extends Component { isPreviousBlockADefaultEmptyBlock, isParentOfSelectedBlock, isDraggable, + className, } = this.props; const isHovered = this.state.isHovered && ! isMultiSelecting; const { name: blockName, isValid } = block; @@ -439,7 +440,7 @@ export class BlockListBlock extends Component { 'is-typing': isTypingWithinBlock, 'is-focused': isFocusMode && ( isSelected || isParentOfSelectedBlock ), 'is-focus-mode': isFocusMode, - } ); + }, className ); const { onReplace } = this.props; diff --git a/packages/editor/src/components/rich-text/index.js b/packages/editor/src/components/rich-text/index.js index 9b5945ff39df15..45158696b7a3d2 100644 --- a/packages/editor/src/components/rich-text/index.js +++ b/packages/editor/src/components/rich-text/index.js @@ -853,11 +853,11 @@ export class RichText extends Component { } ).body.innerHTML; } - valueToFormat( { formats, text } ) { + valueToFormat( value ) { // Handle deprecated `children` and `node` sources. if ( this.usedDeprecatedChildrenSource ) { return children.fromDOM( unstableToDom( { - value: { formats, text }, + value, multilineTag: this.multilineTag, multilineWrapperTags: this.multilineWrapperTags, } ).body.childNodes ); @@ -865,13 +865,13 @@ export class RichText extends Component { if ( this.props.format === 'string' ) { return toHTMLString( { - value: { formats, text }, + value, multilineTag: this.multilineTag, multilineWrapperTags: this.multilineWrapperTags, } ); } - return { formats, text }; + return value; } render() { diff --git a/test/e2e/specs/__snapshots__/plugins-api.test.js.snap b/test/e2e/specs/__snapshots__/plugins-api.test.js.snap index 0b62fc522bf7ef..5916648d1dd2b5 100644 --- a/test/e2e/specs/__snapshots__/plugins-api.test.js.snap +++ b/test/e2e/specs/__snapshots__/plugins-api.test.js.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Using Plugins API Sidebar Should open plugins sidebar using More Menu item and render content 1`] = `"
Sidebar title plugin
"`; +exports[`Using Plugins API Sidebar Should open plugins sidebar using More Menu item and render content 1`] = `"
Sidebar title plugin
"`; diff --git a/test/e2e/specs/plugins-api.test.js b/test/e2e/specs/plugins-api.test.js index d149cf492a5a33..bc841eb9e52f67 100644 --- a/test/e2e/specs/plugins-api.test.js +++ b/test/e2e/specs/plugins-api.test.js @@ -11,6 +11,14 @@ import { } from '../support/utils'; import { activatePlugin, deactivatePlugin } from '../support/plugins'; +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 itemButton.click(); +}; + +const ANNOTATIONS_SELECTOR = '.annotation-text-e2e-tests'; + describe( 'Using Plugins API', () => { beforeAll( async () => { await activatePlugin( 'gutenberg-test-plugin-plugins-api' ); @@ -75,4 +83,36 @@ describe( 'Using Plugins API', () => { expect( pluginSidebarClosed ).toBeNull(); } ); } ); + + describe( 'Annotations', () => { + it( 'Allows a block to be annotated', async () => { + await page.keyboard.type( 'Title' + '\n' + 'Paragraph to annotate' ); + await clickOnMoreMenuItem( 'Sidebar title plugin' ); + + let annotations = await page.$$( ANNOTATIONS_SELECTOR ); + expect( annotations ).toHaveLength( 0 ); + + // Click add annotation button. + const addAnnotationButton = ( await page.$x( "//button[contains(text(), 'Add annotation')]" ) )[ 0 ]; + await addAnnotationButton.click(); + + annotations = await page.$$( ANNOTATIONS_SELECTOR ); + expect( annotations ).toHaveLength( 1 ); + + const annotation = annotations[ 0 ]; + + const text = await page.evaluate( ( el ) => el.innerText, annotation ); + expect( text ).toBe( ' to ' ); + + await clickOnBlockSettingsMenuItem( 'Edit as HTML' ); + + const htmlContent = await page.$$( '.editor-block-list__block-html-textarea' ); + const html = await page.evaluate( ( el ) => { + return el.innerHTML; + }, htmlContent[ 0 ] ); + + // There should be no tags in the raw content. + expect( html ).toBe( '<p>Paragraph to annotate</p>' ); + } ); + } ); } ); diff --git a/test/e2e/test-plugins/plugins-api.php b/test/e2e/test-plugins/plugins-api.php index fcd9fb04b6a2f6..d219eab684f955 100644 --- a/test/e2e/test-plugins/plugins-api.php +++ b/test/e2e/test-plugins/plugins-api.php @@ -45,6 +45,7 @@ 'wp-element', 'wp-i18n', 'wp-plugins', + 'wp-annotations', ), filemtime( plugin_dir_path( __FILE__ ) . 'plugins-api/sidebar.js' ), true diff --git a/test/e2e/test-plugins/plugins-api/sidebar.js b/test/e2e/test-plugins/plugins-api/sidebar.js index 10112e3770155c..c97d29c754f23a 100644 --- a/test/e2e/test-plugins/plugins-api/sidebar.js +++ b/test/e2e/test-plugins/plugins-api/sidebar.js @@ -5,6 +5,8 @@ var compose = wp.compose.compose; var withDispatch = wp.data.withDispatch; var withSelect = wp.data.withSelect; + var select = wp.data.select; + var dispatch = wp.data.dispatch; var PlainText = wp.editor.PlainText; var Fragment = wp.element.Fragment; var el = wp.element.createElement; @@ -48,6 +50,24 @@ }, __( 'Reset' ) ) + ), + el( + Button, + { + isPrimary: true, + onClick: () => { + dispatch( 'core/annotations' ).__experimentalAddAnnotation( { + source: 'e2e-tests', + blockClientId: select( 'core/editor' ).getBlockOrder()[ 0 ], + richTextIdentifier: 'content', + range: { + start: 9, + end: 13, + }, + } ); + }, + }, + __( 'Add annotation' ) ) ); } diff --git a/webpack.config.js b/webpack.config.js index dedeac0858017c..afd5c17c28c937 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -35,6 +35,7 @@ function camelCaseDash( string ) { const gutenbergPackages = [ 'a11y', + 'annotations', 'api-fetch', 'autop', 'blob',