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`] = `"
(no title)
Sidebar title plugin
"`; +exports[`Using Plugins API Sidebar Should open plugins sidebar using More Menu item and render content 1`] = `"
(no title)
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',