diff --git a/blocks/api/factory.js b/blocks/api/factory.js index 796c60fac2309c..7beaf4b596163d 100644 --- a/blocks/api/factory.js +++ b/blocks/api/factory.js @@ -46,11 +46,6 @@ export function createBlock( name, blockAttributes = {} ) { return result; }, {} ); - // Keep the anchor if the block supports it - if ( blockType.supportAnchor && blockAttributes.anchor ) { - attributes.anchor = blockAttributes.anchor; - } - // Keep the className if the block supports it if ( blockType.className !== false && blockAttributes.className ) { attributes.className = blockAttributes.className; diff --git a/blocks/api/index.js b/blocks/api/index.js index 30a3dadb11f0a2..4cb6c022af85da 100644 --- a/blocks/api/index.js +++ b/blocks/api/index.js @@ -19,4 +19,5 @@ export { getDefaultBlockName, getBlockType, getBlockTypes, + hasBlockSupport, } from './registration'; diff --git a/blocks/api/parser.js b/blocks/api/parser.js index ff78f55f0b8fa4..159f8c9ecdb7bc 100644 --- a/blocks/api/parser.js +++ b/blocks/api/parser.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { parse as hpqParse, attr } from 'hpq'; +import { parse as hpqParse } from 'hpq'; import { mapValues, reduce, pickBy } from 'lodash'; /** @@ -148,11 +148,6 @@ export function getBlockAttributes( blockType, innerHTML, attributes ) { return result; }, {} ); - // If the block supports anchor, parse the id - if ( blockType.supportAnchor ) { - blockAttributes.anchor = hpqParse( innerHTML, attr( '*', 'id' ) ); - } - // If the block supports a custom className parse it if ( blockType.className !== false && attributes && attributes.className ) { blockAttributes.className = attributes.className; diff --git a/blocks/api/registration.js b/blocks/api/registration.js index ed6c4fa1a436c6..b220d9778f9317 100644 --- a/blocks/api/registration.js +++ b/blocks/api/registration.js @@ -10,6 +10,11 @@ import { get, isFunction, some } from 'lodash'; */ import { getCategories } from './categories'; +/** + * Internal dependencies + */ +import { applyFilters } from '../hooks'; + /** * Block settings keyed by block name. * @@ -113,13 +118,15 @@ export function registerBlockType( name, settings ) { if ( ! settings.icon ) { settings.icon = 'block-default'; } - const block = blocks[ name ] = { + settings = { name, attributes: get( window._wpBlocksAttributes, name ), ...settings, }; - return block; + settings = applyFilters( 'registerBlockType', settings, name ); + + return blocks[ name ] = settings; } /** @@ -196,3 +203,23 @@ export function getBlockType( name ) { export function getBlockTypes() { return Object.values( blocks ); } + +/** + * Returns true if the block defines support for a feature, or false otherwise + * + * @param {(String|Object)} nameOrType Block name or type object + * @param {String} feature Feature to test + * @param {Boolean} defaultSupports Whether feature is supported by + * default if not explicitly defined + * @return {Boolean} Whether block supports feature + */ +export function hasBlockSupport( nameOrType, feature, defaultSupports ) { + const blockType = 'string' === typeof nameOrType ? + getBlockType( nameOrType ) : + nameOrType; + + return !! get( blockType, [ + 'supports', + feature, + ], defaultSupports ); +} diff --git a/blocks/api/serializer.js b/blocks/api/serializer.js index 2c6980810e9c37..4c88e5275fb110 100644 --- a/blocks/api/serializer.js +++ b/blocks/api/serializer.js @@ -14,6 +14,7 @@ import { Component, createElement, renderToString, cloneElement, Children } from * Internal dependencies */ import { getBlockType, getUnknownTypeHandlerName } from './registration'; +import { applyFilters } from '../hooks'; /** * Returns the block's default classname from its name @@ -55,7 +56,7 @@ export function getSaveContent( blockType, attributes ) { return element; } - const extraProps = {}; + const extraProps = applyFilters( 'getSaveContent.extraProps', {}, blockType, attributes ); if ( !! className ) { const updatedClassName = classnames( className, @@ -65,10 +66,6 @@ export function getSaveContent( blockType, attributes ) { extraProps.className = updatedClassName; } - if ( blockType.supportAnchor && attributes.anchor ) { - extraProps.id = attributes.anchor; - } - return cloneElement( element, extraProps ); }; const contentWithClassname = Children.map( saveContent, addAdvancedAttributes ); diff --git a/blocks/api/test/factory.js b/blocks/api/test/factory.js index d5a71698c29b10..893d1ec59e74f3 100644 --- a/blocks/api/test/factory.js +++ b/blocks/api/test/factory.js @@ -57,30 +57,6 @@ describe( 'block factory', () => { expect( typeof block.uid ).toBe( 'string' ); } ); - it( 'should keep the anchor if the block supports it', () => { - registerBlockType( 'core/test-block', { - attributes: { - align: { - type: 'string', - }, - }, - save: noop, - category: 'common', - title: 'test block', - supportAnchor: true, - } ); - const block = createBlock( 'core/test-block', { - align: 'left', - anchor: 'chicken', - } ); - - expect( block.attributes ).toEqual( { - anchor: 'chicken', - align: 'left', - } ); - expect( block.isValid ).toBe( true ); - } ); - it( 'should keep the className if the block supports it', () => { registerBlockType( 'core/test-block', { attributes: {}, diff --git a/blocks/api/test/parser.js b/blocks/api/test/parser.js index 9d248cf6501fda..cd3cf20177b3f7 100644 --- a/blocks/api/test/parser.js +++ b/blocks/api/test/parser.js @@ -157,26 +157,6 @@ describe( 'block parser', () => { } ); } ); - it( 'should parse the anchor if the block supports it', () => { - const blockType = { - attributes: { - content: { - type: 'string', - source: text( 'div' ), - }, - }, - supportAnchor: true, - }; - - const innerHTML = '
Ribs
'; - const attrs = {}; - - expect( getBlockAttributes( blockType, innerHTML, attrs ) ).toEqual( { - content: 'Ribs', - anchor: 'chicken', - } ); - } ); - it( 'should parse the className if the block supports it', () => { const blockType = { attributes: {}, diff --git a/blocks/api/test/registration.js b/blocks/api/test/registration.js index 82ecf564af60e6..4411d93276e607 100644 --- a/blocks/api/test/registration.js +++ b/blocks/api/test/registration.js @@ -17,6 +17,7 @@ import { getDefaultBlockName, getBlockType, getBlockTypes, + hasBlockSupport, } from '../registration'; describe( 'blocks', () => { @@ -272,4 +273,67 @@ describe( 'blocks', () => { ] ); } ); } ); + + describe( 'hasBlockSupport', () => { + it( 'should return false if block has no supports', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + + expect( hasBlockSupport( 'core/test-block', 'foo' ) ).toBe( false ); + } ); + + it( 'should return false if block does not define support by name', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + supports: { + bar: true, + }, + } ); + + expect( hasBlockSupport( 'core/test-block', 'foo' ) ).toBe( false ); + } ); + + it( 'should return custom default supports if block does not define support by name', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + supports: { + bar: true, + }, + } ); + + expect( hasBlockSupport( 'core/test-block', 'foo', true ) ).toBe( true ); + } ); + + it( 'should return true if block type supports', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + supports: { + foo: true, + }, + } ); + + expect( hasBlockSupport( 'core/test-block', 'foo' ) ).toBe( true ); + } ); + + it( 'should return true if block author defines unsupported but truthy value', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + supports: { + foo: 'hmmm', + }, + } ); + + expect( hasBlockSupport( 'core/test-block', 'foo' ) ).toBe( true ); + } ); + + it( 'should handle block settings object as argument to test', () => { + const settings = { + ...defaultBlockSettings, + supports: { + foo: true, + }, + }; + + expect( hasBlockSupport( settings, 'foo' ) ).toBe( true ); + } ); + } ); } ); diff --git a/blocks/api/test/serializer.js b/blocks/api/test/serializer.js index 7a64ebd33e3e7f..71f116fd77eec0 100644 --- a/blocks/api/test/serializer.js +++ b/blocks/api/test/serializer.js @@ -121,20 +121,6 @@ describe( 'block serializer', () => { expect( saved ).toBe( '
Bananas
' ); } ); - - it( 'should add an id if the block supports anchors', () => { - const saved = getSaveContent( - { - save: ( { attributes } ) => createElement( 'div', null, attributes.fruit ), - supportAnchor: true, - name: 'myplugin/fruit', - className: false, - }, - { fruit: 'Bananas', anchor: 'my-fruit' } - ); - - expect( saved ).toBe( '
Bananas
' ); - } ); } ); describe( 'component save', () => { diff --git a/blocks/block-edit/index.js b/blocks/block-edit/index.js new file mode 100644 index 00000000000000..ee1cb389cb47bf --- /dev/null +++ b/blocks/block-edit/index.js @@ -0,0 +1,26 @@ +/** + * Internal dependencies + */ +import { getBlockType } from '../api'; +import { applyFilters } from '../hooks'; + +function BlockEdit( props ) { + const { name, ...editProps } = props; + const blockType = getBlockType( name ); + + if ( ! blockType ) { + return null; + } + + // `edit` and `save` are functions or components describing the markup + // with which a block is displayed. If `blockType` is valid, assign + // them preferencially as the render value for the block. + let Edit; + if ( blockType ) { + Edit = blockType.edit || blockType.save; + } + + return applyFilters( 'BlockEdit', , props ); +} + +export default BlockEdit; diff --git a/blocks/block-edit/test/index.js b/blocks/block-edit/test/index.js new file mode 100644 index 00000000000000..c24648e6f47959 --- /dev/null +++ b/blocks/block-edit/test/index.js @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import BlockEdit from '../'; +import { + registerBlockType, + unregisterBlockType, + getBlockTypes, +} from '../../api'; + +describe( 'BlockEdit', () => { + 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.type() ).toBe( edit ); + } ); + + 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.type() ).toBe( save ); + } ); +} ); diff --git a/blocks/hooks/anchor.js b/blocks/hooks/anchor.js new file mode 100644 index 00000000000000..00274520932e8b --- /dev/null +++ b/blocks/hooks/anchor.js @@ -0,0 +1,99 @@ +/** + * External dependencies + */ +import { assign } from 'lodash'; + +/** + * WordPress dependencies + */ +import { cloneElement } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { source, hasBlockSupport } from '../api'; +import InspectorControls from '../inspector-controls'; + +/** + * Regular expression matching invalid anchor characters for replacement. + * + * @type {RegExp} + */ +const ANCHOR_REGEX = /[\s#]/g; + +/** + * Filters registered block settings, extending attributes with anchor using ID + * of the first node + * + * @param {Object} settings Original block settings + * @return {Object} Filtered block settings + */ +export function addAttribute( settings ) { + if ( hasBlockSupport( settings, 'anchor' ) ) { + // Use Lodash's assign to gracefully handle if attributes are undefined + settings.attributes = assign( settings.attributes, { + anchor: { + type: 'string', + source: source.attr( '*', 'id' ), + }, + } ); + } + + return settings; +} + +/** + * Override the default edit UI to include a new block inspector control for + * assigning the anchor ID, if block supports anchor + * + * @param {Element} element Original edit element + * @param {Object} props Props passed to BlockEdit + * @return {Element} Filtered edit element + */ +export function addInspectorControl( element, props ) { + if ( hasBlockSupport( props.name, 'anchor' ) && props.focus ) { + element = [ + cloneElement( element, { key: 'edit' } ), + + { + nextValue = nextValue.replace( ANCHOR_REGEX, '-' ); + + props.setAttributes( { + anchor: nextValue, + } ); + } } /> + , + ]; + } + + return element; +} + +/** + * Override props assigned to save component to inject anchor ID, if block + * supports anchor. This is only applied if the block's save result is an + * element and not a markup string. + * + * @param {Object} extraProps Additional props applied to save element + * @param {Object} blockType Block type + * @param {Object} attributes Current block attributes + * @return {Object} Filtered props applied to save element + */ +export function addSaveProps( extraProps, blockType, attributes ) { + if ( hasBlockSupport( blockType, 'anchor' ) ) { + extraProps.id = attributes.anchor; + } + + return extraProps; +} + +export default function anchor( { addFilter } ) { + addFilter( 'registerBlockType', 'core\anchor-attribute', addAttribute ); + addFilter( 'BlockEdit', 'core\anchor-inspector-control', addInspectorControl ); + addFilter( 'getSaveContent.extraProps', 'core\anchor-save-props', addSaveProps ); +} diff --git a/blocks/hooks/index.js b/blocks/hooks/index.js new file mode 100644 index 00000000000000..1a61cfddb51f16 --- /dev/null +++ b/blocks/hooks/index.js @@ -0,0 +1,47 @@ +/** + * WordPress dependencies + */ +import createHooks from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import anchor from './anchor'; + +const hooks = createHooks(); + +const { + addAction, + addFilter, + removeAction, + removeFilter, + removeAllActions, + removeAllFilters, + doAction, + applyFilters, + doingAction, + doingFilter, + didAction, + didFilter, + hasAction, + hasFilter, +} = hooks; + +export { + addAction, + addFilter, + removeAction, + removeFilter, + removeAllActions, + removeAllFilters, + doAction, + applyFilters, + doingAction, + doingFilter, + didAction, + didFilter, + hasAction, + hasFilter, +}; + +anchor( hooks ); diff --git a/blocks/hooks/test/anchor.js b/blocks/hooks/test/anchor.js new file mode 100644 index 00000000000000..1fcecf44d6a0d5 --- /dev/null +++ b/blocks/hooks/test/anchor.js @@ -0,0 +1,79 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * External dependencies + */ +import createHooks from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import anchor from '../anchor'; + +describe( 'anchor', () => { + const hooks = createHooks(); + + let blockSettings; + beforeEach( () => { + anchor( hooks ); + + blockSettings = { + save: noop, + category: 'common', + title: 'block title', + }; + } ); + + afterEach( () => { + hooks.removeAllFilters( 'registerBlockType' ); + hooks.removeAllFilters( 'getSaveContent.extraProps' ); + } ); + + describe( 'addAttribute()', () => { + const addAttribute = hooks.applyFilters.bind( null, 'registerBlockType' ); + + it( 'should do nothing if the block settings do not define anchor support', () => { + const settings = addAttribute( blockSettings ); + + expect( settings.attributes ).toBe( undefined ); + } ); + + it( 'should assign a new anchor attribute', () => { + const settings = addAttribute( { + ...blockSettings, + supports: { + anchor: true, + }, + } ); + + expect( settings.attributes ).toHaveProperty( 'anchor' ); + } ); + } ); + + describe( 'addSaveProps', () => { + const addSaveProps = hooks.applyFilters.bind( null, 'getSaveContent.extraProps' ); + + it( 'should do nothing if the block settings do not define anchor support', () => { + const attributes = { anchor: 'foo' }; + const extraProps = addSaveProps( blockSettings, attributes ); + + expect( extraProps ).not.toHaveProperty( 'id' ); + } ); + + it( 'should inject anchor attribute ID', () => { + const attributes = { anchor: 'foo' }; + blockSettings = { + ...blockSettings, + supports: { + anchor: true, + }, + }; + const extraProps = addSaveProps( {}, blockSettings, attributes ); + + expect( extraProps.id ).toBe( 'foo' ); + } ); + } ); +} ); diff --git a/blocks/index.js b/blocks/index.js index faebbaa6c03fb3..ef56387023922d 100644 --- a/blocks/index.js +++ b/blocks/index.js @@ -13,10 +13,12 @@ import './library'; // Blocks are inferred from the HTML source of a post through a parsing mechanism // and then stored as objects in state, from which it is then rendered for editing. export * from './api'; +export * from './hooks'; 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 BlockDescription } from './block-description'; +export { default as BlockEdit } from './block-edit'; export { default as BlockIcon } from './block-icon'; export { default as ColorPalette } from './color-palette'; export { default as Editable } from './editable'; diff --git a/blocks/library/heading/index.js b/blocks/library/heading/index.js index 5e4f01dc44d5a9..d61c2024565bc9 100644 --- a/blocks/library/heading/index.js +++ b/blocks/library/heading/index.js @@ -29,7 +29,9 @@ registerBlockType( 'core/heading', { className: false, - supportAnchor: true, + supports: { + anchor: true, + }, attributes: { content: { diff --git a/docs/block-api.md b/docs/block-api.md index fe66d6dcec01a8..1e230e9e3f4bd0 100644 --- a/docs/block-api.md +++ b/docs/block-api.md @@ -119,16 +119,17 @@ Whether a block can only be used once per post. useOnce: true, ``` -#### supportAnchor (optional) +#### supports (optional) -* **Type:** `Bool` -* **Default:** `false` +* **Type:** `Object` + +Optional block extended support features. The following options are supported, and should be specified as a boolean `true` or `false` value: -Anchors let you link directly to a specific block on a page. This property adds a field to define an id for the block and a button to copy the direct link. +- `anchor` (default `false`): Anchors let you link directly to a specific block on a page. This property adds a field to define an id for the block and a button to copy the direct link. ```js // Add the support for an anchor link. -supportAnchor: true, +anchor: true, ``` #### supportHTML (optional) diff --git a/editor/components/block-inspector/advanced-controls.js b/editor/components/block-inspector/advanced-controls.js index 6df4d92c4da4a7..70f4df8b418714 100644 --- a/editor/components/block-inspector/advanced-controls.js +++ b/editor/components/block-inspector/advanced-controls.js @@ -9,29 +9,19 @@ import { connect } from 'react-redux'; import { Component } from '@wordpress/element'; import { getBlockType, InspectorControls } from '@wordpress/blocks'; import { __ } from '@wordpress/i18n'; -import { ClipboardButton, Tooltip, PanelBody } from '@wordpress/components'; +import { PanelBody } from '@wordpress/components'; /** * Internal Dependencies */ import { updateBlockAttributes } from '../../actions'; import { getSelectedBlock, getCurrentPost } from '../../selectors'; -import { filterURLForDisplay } from '../../utils/url'; - -/** - * Internal constants - */ -const ANCHOR_REGEX = /[\s#]/g; class BlockInspectorAdvancedControls extends Component { constructor() { super( ...arguments ); - this.state = { - showCopyConfirmation: false, - }; - this.onCopy = this.onCopy.bind( this ); + this.setClassName = this.setClassName.bind( this ); - this.setAnchor = this.setAnchor.bind( this ); } setClassName( className ) { @@ -39,32 +29,10 @@ class BlockInspectorAdvancedControls extends Component { setAttributes( selectedBlock.uid, { className } ); } - setAnchor( anchor ) { - const { selectedBlock, setAttributes } = this.props; - setAttributes( selectedBlock.uid, { anchor: anchor.replace( ANCHOR_REGEX, '-' ) } ); - } - - componentWillUnmout() { - clearTimeout( this.dismissCopyConfirmation ); - } - - onCopy() { - this.setState( { - showCopyConfirmation: true, - } ); - - clearTimeout( this.dismissCopyConfirmation ); - this.dismissCopyConfirmation = setTimeout( () => { - this.setState( { - showCopyConfirmation: false, - } ); - }, 4000 ); - } - render() { - const { selectedBlock, post } = this.props; + const { selectedBlock } = this.props; const blockType = getBlockType( selectedBlock.name ); - if ( false === blockType.className && ! blockType.supportAnchor ) { + if ( false === blockType.className ) { return null; } @@ -80,24 +48,6 @@ class BlockInspectorAdvancedControls extends Component { value={ selectedBlock.attributes.className || '' } onChange={ this.setClassName } /> } - { blockType.supportAnchor && -
- - { !! post.link && !! selectedBlock.attributes.anchor && -
- - -
{ this.state.showCopyConfirmation ? __( 'Copied!' ) : __( 'Copy Link' ) }
-
-
-
- } -
- } ); } diff --git a/editor/modes/visual-editor/block.js b/editor/modes/visual-editor/block.js index 530810bbe9c695..02cb53d21b4234 100644 --- a/editor/modes/visual-editor/block.js +++ b/editor/modes/visual-editor/block.js @@ -10,7 +10,7 @@ import { has, partial, reduce, size } from 'lodash'; */ import { Component, createElement } from '@wordpress/element'; import { keycodes } from '@wordpress/utils'; -import { getBlockType, getBlockDefaultClassname, createBlock } from '@wordpress/blocks'; +import { getBlockType, BlockEdit, getBlockDefaultClassname, createBlock } from '@wordpress/blocks'; import { __, sprintf } from '@wordpress/i18n'; /** @@ -318,20 +318,7 @@ class VisualEditorBlock extends Component { // translators: %s: Type of block (i.e. Text, Image etc) const blockLabel = sprintf( __( 'Block: %s' ), blockType.title ); // The block as rendered in the editor is composed of general block UI - // (mover, toolbar, wrapper) and the display of the block content, which - // is referred to as . - let BlockEdit; - // `edit` and `save` are functions or components describing the markup - // with which a block is displayed. If `blockType` is valid, assign - // them preferencially as the render value for the block. - if ( blockType ) { - BlockEdit = blockType.edit || blockType.save; - } - - // Should `BlockEdit` return as null, we have nothing to display for the block. - if ( ! BlockEdit ) { - return null; - } + // (mover, toolbar, wrapper) and the display of the block content. // Generate the wrapper class names handling the different states of the block. const { isHovered, isSelected, isMultiSelected, isFirstMultiSelected, focus } = this.props; @@ -389,6 +376,7 @@ class VisualEditorBlock extends Component { { isValid && mode === 'visual' && (