diff --git a/core-blocks/gallery/style.scss b/core-blocks/gallery/style.scss index 90152b22662b58..869f7d878d285d 100644 --- a/core-blocks/gallery/style.scss +++ b/core-blocks/gallery/style.scss @@ -38,6 +38,10 @@ width: 100%; max-height: 100%; overflow: auto; + + img { + display: inline; + } } } diff --git a/core-blocks/image/editor.scss b/core-blocks/image/editor.scss index edf2a48fb5e858..2fa87c27723466 100644 --- a/core-blocks/image/editor.scss +++ b/core-blocks/image/editor.scss @@ -14,6 +14,10 @@ &.is-transient img { @include loading_fade; } + + figcaption img { + display: inline; + } } .wp-block-image__resize-handler-top-right, diff --git a/core-inline-blocks/index.js b/core-inline-blocks/index.js new file mode 100644 index 00000000000000..e380714c96aea1 --- /dev/null +++ b/core-inline-blocks/index.js @@ -0,0 +1,17 @@ +/** + * WordPress dependencies + */ +import { registerInlineBlockType } from '../inline-blocks'; + +/** + * Internal dependencies + */ +import * as inlineImage from './inline-image'; + +export const registerCoreInlineBlocks = () => { + [ + inlineImage, + ].forEach( ( { name, settings } ) => { + registerInlineBlockType( name, settings ); + } ); +}; diff --git a/core-inline-blocks/inline-image.js b/core-inline-blocks/inline-image.js new file mode 100644 index 00000000000000..829cc6c4cb9f04 --- /dev/null +++ b/core-inline-blocks/inline-image.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +export const settings = { + id: 'inline-image', + + title: __( 'Inline Image' ), + + type: 'image', + + icon: 'format-image', + + render( { id, url, alt, width } ) { + const imgWidth = width > 150 ? 150 : width; + // set width in style attribute to prevent Block CSS from overriding it + const img = `${ alt }`; + + return img; + }, +}; + +export const name = 'core/inline-image'; diff --git a/edit-post/hooks/blocks/media-upload/index.js b/edit-post/hooks/blocks/media-upload/index.js index fe20dad65ec2ec..59d2c115f363b3 100644 --- a/edit-post/hooks/blocks/media-upload/index.js +++ b/edit-post/hooks/blocks/media-upload/index.js @@ -81,6 +81,7 @@ class MediaUpload extends Component { this.onOpen = this.onOpen.bind( this ); this.onSelect = this.onSelect.bind( this ); this.onUpdate = this.onUpdate.bind( this ); + this.onClose = this.onClose.bind( this ); this.processMediaCaption = this.processMediaCaption.bind( this ); if ( gallery ) { @@ -122,6 +123,7 @@ class MediaUpload extends Component { this.frame.on( 'select', this.onSelect ); this.frame.on( 'update', this.onUpdate ); this.frame.on( 'open', this.onOpen ); + this.frame.on( 'close', this.onClose ); } componentWillUnmount() { @@ -169,6 +171,14 @@ class MediaUpload extends Component { getAttachmentsCollection( castArray( this.props.value ) ).more(); } + onClose() { + const { onClose } = this.props; + + if ( onClose ) { + onClose(); + } + } + openModal() { this.frame.open(); } diff --git a/editor/components/inserter/index.js b/editor/components/inserter/index.js index ea6b0aafd84f10..362cf4b22325c6 100644 --- a/editor/components/inserter/index.js +++ b/editor/components/inserter/index.js @@ -16,21 +16,26 @@ import { withSelect, withDispatch } from '@wordpress/data'; * Internal dependencies */ import InserterMenu from './menu'; +import InserterInlineMenu from './inline-menu'; class Inserter extends Component { constructor() { super( ...arguments ); this.onToggle = this.onToggle.bind( this ); + this.isInsertingInline = this.isInsertingInline.bind( this ); + this.showInsertionPoint = this.showInsertionPoint.bind( this ); + this.hideInsertionPoint = this.hideInsertionPoint.bind( this ); + this.state = { isInline: false }; } onToggle( isOpen ) { const { onToggle } = this.props; if ( isOpen ) { - this.props.showInsertionPoint(); + this.showInsertionPoint(); } else { - this.props.hideInsertionPoint(); + this.hideInsertionPoint(); } // Surface toggle callback to parent component @@ -39,15 +44,47 @@ class Inserter extends Component { } } + showInsertionPoint() { + const { showInlineInsertionPoint, showInsertionPoint } = this.props; + + if ( this.isInsertingInline() ) { + this.setState( { isInline: true } ); + showInlineInsertionPoint(); + } else { + this.setState( { isInline: false } ); + showInsertionPoint(); + } + } + + hideInsertionPoint() { + const { hideInlineInsertionPoint, hideInsertionPoint } = this.props; + + if ( this.state.isInline ) { + hideInlineInsertionPoint(); + } else { + hideInsertionPoint(); + } + } + + isInsertingInline() { + const { selectedBlock, canInsertInline } = this.props; + + return selectedBlock && + ! isUnmodifiedDefaultBlock( selectedBlock ) && + canInsertInline; + } + render() { const { position, title, children, onInsertBlock, + onInsertInline, hasSupportedBlocks, isLocked, } = this.props; + const { isInline } = this.state; if ( ! hasSupportedBlocks || isLocked ) { return null; @@ -74,11 +111,23 @@ class Inserter extends Component { ) } renderContent={ ( { onClose } ) => { const onSelect = ( item ) => { - onInsertBlock( item ); + if ( isInline ) { + onInsertInline( item.name ); + } else { + onInsertBlock( item ); + } onClose(); }; + if ( isInline ) { + return ( + + ); + } + return ; } } /> @@ -94,6 +143,7 @@ export default compose( [ getSelectedBlock, getSupportedBlocks, getEditorSettings, + isInlineInsertAvailable, } = select( 'core/editor' ); const { allowedBlockTypes, templateLock } = getEditorSettings(); const insertionPoint = getBlockInsertionPoint(); @@ -105,6 +155,7 @@ export default compose( [ selectedBlock: getSelectedBlock(), hasSupportedBlocks: true === supportedBlocks || ! isEmpty( supportedBlocks ), isLocked: !! templateLock, + canInsertInline: isInlineInsertAvailable(), }; } ), withDispatch( ( dispatch, ownProps ) => ( { @@ -120,5 +171,8 @@ export default compose( [ } return dispatch( 'core/editor' ).insertBlock( insertedBlock, index, rootUID ); }, + showInlineInsertionPoint: dispatch( 'core/editor' ).showInlineInsertionPoint, + hideInlineInsertionPoint: dispatch( 'core/editor' ).hideInlineInsertionPoint, + onInsertInline: dispatch( 'core/editor' ).insertInline, } ) ), ] )( Inserter ); diff --git a/editor/components/inserter/inline-menu.js b/editor/components/inserter/inline-menu.js new file mode 100644 index 00000000000000..84994286589eb3 --- /dev/null +++ b/editor/components/inserter/inline-menu.js @@ -0,0 +1,37 @@ +/** + * WordPress dependencies + */ +import { Component, Fragment } from '@wordpress/element'; +import { registerCoreInlineBlocks } from '../../../core-inline-blocks'; +import { getInlineBlockTypes } from '../../../inline-blocks'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import InserterGroup from './group'; + +// TODO: move this to lib/client-assets.php +registerCoreInlineBlocks(); + +export default class InserterInlineMenu extends Component { + render() { + const inlineBlocks = getInlineBlockTypes(); + + return ( + + + + + ); + } +} diff --git a/editor/components/rich-text/index.js b/editor/components/rich-text/index.js index 200c34eacd3540..3a687a76b53b29 100644 --- a/editor/components/rich-text/index.js +++ b/editor/components/rich-text/index.js @@ -39,6 +39,7 @@ import Autocomplete from '../autocomplete'; import BlockFormatControls from '../block-format-controls'; import FormatToolbar from './format-toolbar'; import TinyMCE from './tinymce'; +import InlineBlocks from './inline-blocks'; import { pickAriaProps } from './aria'; import patterns from './patterns'; import { EVENTS } from './constants'; @@ -871,6 +872,12 @@ export class RichText extends Component { { formatToolbar } ) } + { isSelected && + + } { ( { isExpanded, listBoxId, activeId } ) => ( diff --git a/editor/components/rich-text/inline-blocks.js b/editor/components/rich-text/inline-blocks.js new file mode 100644 index 00000000000000..ee7b55f15663a2 --- /dev/null +++ b/editor/components/rich-text/inline-blocks.js @@ -0,0 +1,152 @@ +/** + * WordPress dependencies + */ +import { Component, Fragment, compose } from '@wordpress/element'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { withSafeTimeout } from '@wordpress/components'; +import { getRectangleFromRange } from '@wordpress/utils'; + +/** + * Internal dependencies + */ +import InlineInsertionPoint from './inline-insertion-point'; +import MediaUpload from '../media-upload'; + +class InlineBlocks extends Component { + constructor() { + super( ...arguments ); + + this.insert = this.insert.bind( this ); + this.getInsertPosition = this.getInsertPosition.bind( this ); + this.onSelectMedia = this.onSelectMedia.bind( this ); + this.openMediaLibrary = this.openMediaLibrary.bind( this ); + this.closeMediaLibrary = this.closeMediaLibrary.bind( this ); + this.state = { mediaLibraryOpen: false }; + } + + componentDidMount() { + const { setTimeout, setInsertAvailable } = this.props; + + // When moving between two different RichText with the keyboard, we need to + // make sure `setInsertAvailable` is called after `setInsertUnavailable` + // from previous RichText so that editor state is correct + setTimeout( setInsertAvailable ); + } + + componentDidUpdate( prevProps ) { + if ( + this.props.inlineBlockForInsert && + ! prevProps.inlineBlockForInsert + ) { + this.insert(); + } + } + + componentWillUnmount() { + this.props.setInsertUnavailable(); + } + + getInsertPosition() { + const { containerRef, editor } = this.props; + + // The container is relatively positioned. + const containerPosition = containerRef.current.getBoundingClientRect(); + const rect = getRectangleFromRange( editor.selection.getRng() ); + + return { + top: rect.top - containerPosition.top, + left: rect.right - containerPosition.left, + height: rect.height, + }; + } + + insert() { + const { + inlineBlockForInsert, + completeInsert, + editor, + } = this.props; + + if ( inlineBlockForInsert.type === 'image' ) { + this.openMediaLibrary(); + } else { + editor.insertContent( inlineBlockForInsert.render() ); + completeInsert(); + } + } + + onSelectMedia( media ) { + const { + editor, + inlineBlockForInsert, + completeInsert, + } = this.props; + const img = inlineBlockForInsert.render( media ); + + editor.insertContent( img ); + completeInsert(); + this.closeMediaLibrary(); + } + + openMediaLibrary() { + this.setState( { mediaLibraryOpen: true } ); + } + + closeMediaLibrary() { + this.setState( { mediaLibraryOpen: false } ); + } + + render() { + const { isInlineInsertionPointVisible } = this.props; + const { mediaLibraryOpen } = this.state; + + return ( + + { isInlineInsertionPointVisible && + + } + { mediaLibraryOpen && + { + open(); + return null; + } } + /> + } + + ); + } +} + +export default compose( [ + withSelect( ( select ) => { + const { + isInlineInsertionPointVisible, + getInlineBlockForInsert, + } = select( 'core/editor' ); + + return { + isInlineInsertionPointVisible: isInlineInsertionPointVisible(), + inlineBlockForInsert: getInlineBlockForInsert(), + }; + } ), + withDispatch( ( dispatch ) => { + const { + setInlineInsertAvailable, + setInlineInsertUnavailable, + completeInlineInsert, + } = dispatch( 'core/editor' ); + + return { + setInsertAvailable: setInlineInsertAvailable, + setInsertUnavailable: setInlineInsertUnavailable, + completeInsert: completeInlineInsert, + }; + } ), + withSafeTimeout, +] )( InlineBlocks ); diff --git a/editor/components/rich-text/inline-insertion-point/index.js b/editor/components/rich-text/inline-insertion-point/index.js new file mode 100644 index 00000000000000..e986098c85ad5a --- /dev/null +++ b/editor/components/rich-text/inline-insertion-point/index.js @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ +import './style.scss'; + +export default function InlineInsertionPoint( { style } ) { + return ( +
+
+
+
+ ); +} diff --git a/editor/components/rich-text/inline-insertion-point/style.scss b/editor/components/rich-text/inline-insertion-point/style.scss new file mode 100644 index 00000000000000..6a0ffc08dc81a3 --- /dev/null +++ b/editor/components/rich-text/inline-insertion-point/style.scss @@ -0,0 +1,19 @@ +.blocks-inline-insertion-point { + display: inline-block; + z-index: 1; +} + +.blocks-inline-insertion-point__caret { + display: inline-block; + width: 1px; + height: 100%; + margin-right: 1px; + background: $black; +} + +.blocks-inline-insertion-point__indicator { + display: inline-block; + width: 3px; + height: 95%; + background: $blue-medium-500; +} diff --git a/editor/components/rich-text/style.scss b/editor/components/rich-text/style.scss index 0e749d976865cc..1bb37777d826bc 100644 --- a/editor/components/rich-text/style.scss +++ b/editor/components/rich-text/style.scss @@ -61,6 +61,16 @@ } } + img { + &[data-mce-selected] { + outline: none; + } + + &::selection { + background: none !important; + } + } + &[data-is-placeholder-visible="true"] { position: absolute; top: 0; @@ -80,6 +90,19 @@ &.mce-content-body { line-height: $editor-line-height; } + + div.mce-resizehandle { + border-radius: 50%; + border: 1px solid $black; + width: 12px; + height: 12px; + background: $white; + box-sizing: border-box; + + &:hover { + background: $white; + } + } } .block-rich-text__inline-toolbar { diff --git a/editor/store/actions.js b/editor/store/actions.js index 9028f989452dda..1edf8e02b9c56c 100644 --- a/editor/store/actions.js +++ b/editor/store/actions.js @@ -285,6 +285,33 @@ export function insertBlocks( blocks, index, rootUID ) { }; } +/** + * Returns an action object used in signalling that an inline block should be + * inserted. + * + * @param {string} inlineBlockName Name of inline block to insert. + * + * @return {Object} Action object. + */ +export function insertInline( inlineBlockName ) { + return { + type: 'INSERT_INLINE', + inlineBlockName, + }; +} + +/** + * Returns an action object used in signalling that inline block insertion is + * complete. + * + * @return {Object} Action object. + */ +export function completeInlineInsert() { + return { + type: 'INLINE_INSERT_COMPLETE', + }; +} + /** * Returns an action object used in signalling that the insertion point should * be shown. @@ -308,6 +335,53 @@ export function hideInsertionPoint() { }; } +/** + * Returns an action object used in signalling that the inline insertion point + * should be shown. + * + * @return {Object} Action object. + */ +export function showInlineInsertionPoint() { + return { + type: 'SHOW_INLINE_INSERTION_POINT', + }; +} + +/** + * Returns an action object hiding the inline insertion point. + * + * @return {Object} Action object. + */ +export function hideInlineInsertionPoint() { + return { + type: 'HIDE_INLINE_INSERTION_POINT', + }; +} + +/** + * Returns an action object used in signalling that a RichText component is + * selected and available for inline insertion. + * + * @return {Object} Action object. + */ +export function setInlineInsertAvailable() { + return { + type: 'SET_INLINE_INSERT_AVAILABLE', + }; +} + +/** + * Returns an action object used in signalling that inline insertion is not + * available. + * + * @return {Object} Action object. + */ +export function setInlineInsertUnavailable() { + return { + type: 'SET_INLINE_INSERT_UNAVAILABLE', + }; +} + /** * Returns an action object resetting the template validity. * diff --git a/editor/store/reducer.js b/editor/store/reducer.js index 62217591e58d38..6d59dc543b659d 100644 --- a/editor/store/reducer.js +++ b/editor/store/reducer.js @@ -769,6 +769,68 @@ export function isInsertionPointVisible( state = false, action ) { return state; } +/** + * Reducer returning the inline insertion point visibility, a boolean value + * reflecting whether the inline insertion point should be shown. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function isInlineInsertionPointVisible( state = false, action ) { + switch ( action.type ) { + case 'SHOW_INLINE_INSERTION_POINT': + return true; + + case 'HIDE_INLINE_INSERTION_POINT': + return false; + } + + return state; +} + +/** + * Reducer returning an Inline Block name for insertion. + * + * @param {string} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. +*/ +export function inlineBlockNameForInsert( state = null, action ) { + switch ( action.type ) { + case 'INSERT_INLINE': + return action.inlineBlockName; + + case 'INLINE_INSERT_COMPLETE': + return null; + } + + return state; +} + +/** + * Reducer returning a boolean indicating whether a RichText component is + * selected and available for inline block insertion. + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +export function isInlineInsertAvailable( state = false, action ) { + switch ( action.type ) { + case 'SET_INLINE_INSERT_AVAILABLE': + return true; + + case 'SET_INLINE_INSERT_UNAVAILABLE': + return false; + } + + return state; +} + /** * Reducer returning whether the post blocks match the defined template or not. * @@ -1065,6 +1127,9 @@ export default optimist( combineReducers( { blocksMode, blockListSettings, isInsertionPointVisible, + isInlineInsertionPointVisible, + inlineBlockNameForInsert, + isInlineInsertAvailable, preferences, saving, notices, diff --git a/editor/store/selectors.js b/editor/store/selectors.js index d9a1615081253c..91d6665937e694 100644 --- a/editor/store/selectors.js +++ b/editor/store/selectors.js @@ -23,6 +23,7 @@ import createSelector from 'rememo'; * WordPress dependencies */ import { serialize, getBlockType, getBlockTypes } from '@wordpress/blocks'; +import { getInlineBlockType } from '../../inline-blocks'; import { __ } from '@wordpress/i18n'; import { addQueryArgs } from '@wordpress/url'; import { moment } from '@wordpress/date'; @@ -1052,6 +1053,42 @@ export function isBlockInsertionPointVisible( state ) { return state.isInsertionPointVisible; } +/** + * Returns true if we should show the inline insertion point. + * + * @param {Object} state Global application state. + * + * @return {?boolean} Whether the insertion point is visible or not. + */ +export function isInlineInsertionPointVisible( state ) { + return state.isInlineInsertionPointVisible; +} + +/** + * Returns the Inline Block object for insertion. + * + * @param {Object} state Global application state. + * + * @return {Object} Inline Block object, or null when not ready for insert. + */ +export function getInlineBlockForInsert( state ) { + const name = state.inlineBlockNameForInsert; + const inlineBlock = name ? getInlineBlockType( name ) : null; + + return inlineBlock; +} + +/** + * Returns whether a RichText component is selected and available for inline + * insertion. + * + * @param {boolean} state + * @return {boolean} Whether inline insert is available. + */ +export function isInlineInsertAvailable( state ) { + return state.isInlineInsertAvailable; +} + /** * Returns whether the blocks matches the template or not. * diff --git a/editor/store/test/actions.js b/editor/store/test/actions.js index 050ff2473015b3..5ca6ea301982f6 100644 --- a/editor/store/test/actions.js +++ b/editor/store/test/actions.js @@ -26,6 +26,12 @@ import { insertBlocks, showInsertionPoint, hideInsertionPoint, + insertInline, + completeInlineInsert, + showInlineInsertionPoint, + hideInlineInsertionPoint, + setInlineInsertAvailable, + setInlineInsertUnavailable, editPost, savePost, trashPost, @@ -229,6 +235,54 @@ describe( 'actions', () => { } ); } ); + describe( 'insertInline', () => { + it( 'should return the INSERT_INLINE action', () => { + expect( insertInline() ).toEqual( { + type: 'INSERT_INLINE', + } ); + } ); + } ); + + describe( 'completeInlineInsert', () => { + it( 'should return the INLINE_INSERT_COMPLETE action', () => { + expect( completeInlineInsert() ).toEqual( { + type: 'INLINE_INSERT_COMPLETE', + } ); + } ); + } ); + + describe( 'showInlineInsertionPoint', () => { + it( 'should return the SHOW_INLINE_INSERTION_POINT action', () => { + expect( showInlineInsertionPoint() ).toEqual( { + type: 'SHOW_INLINE_INSERTION_POINT', + } ); + } ); + } ); + + describe( 'hideInlineInsertionPoint', () => { + it( 'should return the HIDE_INLINE_INSERTION_POINT action', () => { + expect( hideInlineInsertionPoint() ).toEqual( { + type: 'HIDE_INLINE_INSERTION_POINT', + } ); + } ); + } ); + + describe( 'setInlineInsertAvailable', () => { + it( 'should return the SET_INLINE_INSERT_AVAILABLE action', () => { + expect( setInlineInsertAvailable() ).toEqual( { + type: 'SET_INLINE_INSERT_AVAILABLE', + } ); + } ); + } ); + + describe( 'setInlineInsertUnavailable', () => { + it( 'should return the SET_INLINE_INSERT_UNAVAILABLE action', () => { + expect( setInlineInsertUnavailable() ).toEqual( { + type: 'SET_INLINE_INSERT_UNAVAILABLE', + } ); + } ); + } ); + describe( 'editPost', () => { it( 'should return EDIT_POST action', () => { const edits = { format: 'sample' }; diff --git a/editor/store/test/reducer.js b/editor/store/test/reducer.js index 5bdb63393d6db1..5e93e95baafdf6 100644 --- a/editor/store/test/reducer.js +++ b/editor/store/test/reducer.js @@ -33,6 +33,9 @@ import { provisionalBlockUID, blocksMode, isInsertionPointVisible, + isInlineInsertionPointVisible, + isInlineInsertAvailable, + inlineBlockNameForInsert, sharedBlocks, template, blockListSettings, @@ -1298,6 +1301,81 @@ describe( 'state', () => { } ); } ); + describe( 'isInlineInsertionPointVisible', () => { + it( 'should default to false', () => { + const state = isInlineInsertionPointVisible( undefined, {} ); + + expect( state ).toBe( false ); + } ); + + it( 'should set inline insertion point visible', () => { + const state = isInlineInsertionPointVisible( false, { + type: 'SHOW_INLINE_INSERTION_POINT', + } ); + + expect( state ).toBe( true ); + } ); + + it( 'should clear the inline insertion point', () => { + const state = isInlineInsertionPointVisible( true, { + type: 'HIDE_INLINE_INSERTION_POINT', + } ); + + expect( state ).toBe( false ); + } ); + } ); + + describe( 'isInlineInsertAvailable', () => { + it( 'should default to false', () => { + const state = isInlineInsertAvailable( undefined, {} ); + + expect( state ).toBe( false ); + } ); + + it( 'should set inline insert available', () => { + const state = isInlineInsertAvailable( false, { + type: 'SET_INLINE_INSERT_AVAILABLE', + } ); + + expect( state ).toBe( true ); + } ); + + it( 'should set inline insert unavailable', () => { + const state = isInlineInsertAvailable( true, { + type: 'SET_INLINE_INSERT_UNAVAILABLE', + } ); + + expect( state ).toBe( false ); + } ); + } ); + + describe( 'inlineBlockNameForInsert', () => { + const inlineBlockName = 'core/inline-image'; + + it( 'should default to null', () => { + const state = inlineBlockNameForInsert( undefined, {} ); + + expect( state ).toBe( null ); + } ); + + it( 'should insert inline block name', () => { + const state = inlineBlockNameForInsert( null, { + type: 'INSERT_INLINE', + inlineBlockName, + } ); + + expect( state ).toBe( inlineBlockName ); + } ); + + it( 'should be null after insert complete', () => { + const state = inlineBlockNameForInsert( inlineBlockName, { + type: 'INLINE_INSERT_COMPLETE', + } ); + + expect( state ).toBe( null ); + } ); + } ); + describe( 'isTyping()', () => { it( 'should set the typing flag to true', () => { const state = isTyping( false, { diff --git a/editor/store/test/selectors.js b/editor/store/test/selectors.js index ebcea745c977ea..3e9baab1ecca17 100644 --- a/editor/store/test/selectors.js +++ b/editor/store/test/selectors.js @@ -8,6 +8,7 @@ import { filter, property, union } from 'lodash'; */ import { __ } from '@wordpress/i18n'; import { registerBlockType, unregisterBlockType, getBlockTypes } from '@wordpress/blocks'; +import { getInlineBlockType } from '../../../inline-blocks'; import { moment } from '@wordpress/date'; import { registerCoreBlocks } from '@wordpress/core-blocks'; @@ -67,6 +68,9 @@ const { isTyping, getBlockInsertionPoint, isBlockInsertionPointVisible, + isInlineInsertionPointVisible, + isInlineInsertAvailable, + getInlineBlockForInsert, isSavingPost, didPostSaveRequestSucceed, didPostSaveRequestFail, @@ -2418,6 +2422,45 @@ describe( 'selectors', () => { } ); } ); + describe( 'isInlineInsertionPointVisible', () => { + it( 'should return the value in state', () => { + const state = { + isInlineInsertionPointVisible: true, + }; + + expect( isInlineInsertionPointVisible( state ) ).toBe( true ); + } ); + } ); + + describe( 'isInlineInsertAvailable', () => { + it( 'should return the value in state', () => { + const state = { + isInlineInsertAvailable: true, + }; + + expect( isInlineInsertAvailable( state ) ).toBe( true ); + } ); + } ); + + describe( 'getInlineBlockForInsert', () => { + it( 'should return null when not inserting an inline block', () => { + const state = { + inlineBlockNameForInsert: null, + }; + + expect( getInlineBlockForInsert( state ) ).toBe( null ); + } ); + + it( 'should return inline block object when ready for insert', () => { + const state = { + inlineBlockNameForInsert: 'core/inline-image', + }; + const inlineBlock = getInlineBlockType( 'core/inline-image' ); + + expect( getInlineBlockForInsert( state ) ).toBe( inlineBlock ); + } ); + } ); + describe( 'isSavingPost', () => { it( 'should return true if the post is currently being saved', () => { const state = { diff --git a/inline-blocks/api/index.js b/inline-blocks/api/index.js new file mode 100644 index 00000000000000..2385295ec81cb4 --- /dev/null +++ b/inline-blocks/api/index.js @@ -0,0 +1,82 @@ +/* eslint no-console: [ 'error', { allow: [ 'error' ] } ] */ + +/** + * Inline Block type definitions keyed by inline block name. + * + * @type {Object.} + */ +const inlineBlocks = {}; + +/** + * Registers a new inline block provided a unique name and an object defining + * its behavior. Once registered, the inline block is made available as an + * option to any editor interface where inline blocks are implemented. + * + * @param {string} name Inline Block name. + * @param {Object} settings Inline Block settings. + * + * @return {?WPInlineBlock} The inline block, if it has been successfully + * registered; otherwise `undefined`. + */ +export function registerInlineBlockType( name, settings ) { + settings = { + name, + ...settings, + }; + + if ( typeof name !== 'string' ) { + console.error( + 'Inline Block names must be strings.' + ); + return; + } + if ( ! /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test( name ) ) { + console.error( + 'Inline Block names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-inline-block' + ); + return; + } + if ( inlineBlocks[ name ] ) { + console.error( + 'Inline Block "' + name + '" is already registered.' + ); + return; + } + if ( ! ( 'title' in settings ) || settings.title === '' ) { + console.error( + 'The inline block "' + name + '" must have a title.' + ); + return; + } + if ( typeof settings.title !== 'string' ) { + console.error( + 'Inline Block titles must be strings.' + ); + return; + } + if ( ! settings.icon ) { + settings.icon = 'block-default'; + } + + return inlineBlocks[ name ] = settings; +} + +/** + * Returns a registered inline block type. + * + * @param {string} name Inline Block name. + * + * @return {?Object} Inline Block type. + */ +export function getInlineBlockType( name ) { + return inlineBlocks[ name ]; +} + +/** + * Returns all registered inline blocks. + * + * @return {Array} Inline Block settings. + */ +export function getInlineBlockTypes() { + return Object.values( inlineBlocks ); +} diff --git a/inline-blocks/index.js b/inline-blocks/index.js new file mode 100644 index 00000000000000..b1c13e734067ac --- /dev/null +++ b/inline-blocks/index.js @@ -0,0 +1 @@ +export * from './api'; diff --git a/test/e2e/assets/10x10_e2e_test_image_z9T8jK.png b/test/e2e/assets/10x10_e2e_test_image_z9T8jK.png new file mode 100644 index 00000000000000..4d198c0023578e Binary files /dev/null and b/test/e2e/assets/10x10_e2e_test_image_z9T8jK.png differ diff --git a/test/e2e/specs/adding-blocks.test.js b/test/e2e/specs/adding-blocks.test.js index 973063c4613f40..a2aaa85ccbbacb 100644 --- a/test/e2e/specs/adding-blocks.test.js +++ b/test/e2e/specs/adding-blocks.test.js @@ -72,6 +72,8 @@ describe( 'adding blocks', () => { await page.keyboard.type( 'Quote block' ); // Using the regular inserter + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Tab' ); await page.click( '.edit-post-header [aria-label="Add block"]' ); await page.keyboard.type( 'code' ); await page.keyboard.press( 'Tab' ); diff --git a/test/e2e/specs/adding-inline-blocks.test.js b/test/e2e/specs/adding-inline-blocks.test.js new file mode 100644 index 00000000000000..52b227b112602c --- /dev/null +++ b/test/e2e/specs/adding-inline-blocks.test.js @@ -0,0 +1,81 @@ +/** + * Node dependencies + */ +import path from 'path'; + +/** + * Internal dependencies + */ +import '../support/bootstrap'; +import { newPost, newDesktopBrowserPage } from '../support/utils'; + +const testImage = { + key: 'z9T8jK', + fileName: '10x10_e2e_test_image_z9T8jK.png', + alt: 'test', +}; +const testImagePath = path.join( __dirname, '..', 'assets', testImage.fileName ); + +describe( 'adding inline blocks', () => { + beforeAll( async () => { + await newDesktopBrowserPage(); + await newPost(); + } ); + + it( 'Should insert inline image', async () => { + // Create a paragraph + await page.click( '.editor-default-block-appender' ); + await page.keyboard.type( 'Paragraph with inline image: ' ); + + // Use the global inserter to select Inline Image + await page.click( '.edit-post-header [aria-label="Add block"]' ); + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Enter' ); + + // Select Media Library tab + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Enter' ); + + // Search for test image + await page.keyboard.type( testImage.key ); + + // Wait for image search results + await page.waitFor( 500 ); + + const searchResultElement = await page.$( '.media-frame .attachment' ); + + if ( searchResultElement ) { + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Enter' ); + } else { + // Upload test image + const inputElement = await page.$( 'input[type=file]' ); + await inputElement.uploadFile( testImagePath ); + await page.waitFor( 500 ); + } + + // Enter alt text + await page.click( '.media-frame [data-setting=caption]' ); + await page.keyboard.press( 'Tab' ); + await page.keyboard.type( testImage.alt ); + + // Select image + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Enter' ); + + // Switch to Text Mode to check HTML Output + await page.click( '.edit-post-more-menu [aria-label="More"]' ); + const codeEditorButton = ( await page.$x( '//button[contains(text(), \'Code Editor\')]' ) )[ 0 ]; + await codeEditorButton.click( 'button' ); + + // Assertions + const textEditorContent = await page.$eval( '.editor-post-text-editor', ( element ) => element.value ); + + expect( textEditorContent.indexOf( 'Paragraph with inline image:  { // Creating test blocks await page.click( '.editor-default-block-appender' ); await page.keyboard.type( 'First Paragraph' ); + await page.keyboard.press( 'Enter' ); await page.click( '.edit-post-header [aria-label="Add block"]' ); await page.keyboard.type( 'Image' ); await page.keyboard.press( 'Tab' );