diff --git a/core-blocks/gallery/style.scss b/core-blocks/gallery/style.scss
index 9bdaaafc9105b0..4a3e7a6cb19ba5 100644
--- a/core-blocks/gallery/style.scss
+++ b/core-blocks/gallery/style.scss
@@ -39,6 +39,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 be7a784f71ee77..0aebda1dacc8e5 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/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 9eeca66c55fd36..373980a80b0357 100644
--- a/editor/components/inserter/index.js
+++ b/editor/components/inserter/index.js
@@ -12,20 +12,26 @@ import { withSelect, withDispatch } from '@wordpress/data';
*/
import InserterMenu from './menu';
+export { default as InserterResultsPortal } from './results-portal';
+
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
@@ -34,6 +40,36 @@ 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 {
items,
@@ -42,7 +78,9 @@ class Inserter extends Component {
children,
onInsertBlock,
rootUID,
+ onInsertInline,
} = this.props;
+ const { isInline } = this.state;
if ( items.length === 0 ) {
return null;
@@ -70,12 +108,15 @@ class Inserter extends Component {
) }
renderContent={ ( { onClose } ) => {
const onSelect = ( item ) => {
- onInsertBlock( item );
+ if ( isInline ) {
+ onInsertInline( item );
+ } else {
+ onInsertBlock( item );
+ }
onClose();
};
-
- return ;
+ return ;
} }
/>
);
@@ -89,6 +130,7 @@ export default compose( [
getBlockInsertionPoint,
getSelectedBlock,
getInserterItems,
+ isInlineInsertAvailable,
} = select( 'core/editor' );
const insertionPoint = getBlockInsertionPoint();
const { rootUID } = insertionPoint;
@@ -98,6 +140,7 @@ export default compose( [
selectedBlock: getSelectedBlock(),
items: getInserterItems( rootUID ),
rootUID,
+ canInsertInline: isInlineInsertAvailable(),
};
} ),
withDispatch( ( dispatch, ownProps ) => ( {
@@ -113,5 +156,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/menu.js b/editor/components/inserter/menu.js
index 010229da16d86c..c658a0d6c6f3a6 100644
--- a/editor/components/inserter/menu.js
+++ b/editor/components/inserter/menu.js
@@ -35,6 +35,7 @@ import './style.scss';
import BlockPreview from '../block-preview';
import ItemList from './item-list';
import ChildBlocks from './child-blocks';
+import InserterResultsPortal from './results-portal';
const MAX_SUGGESTED_ITEMS = 9;
@@ -158,7 +159,7 @@ export class InserterMenu extends Component {
}
render() {
- const { instanceId, onSelect, rootUID } = this.props;
+ const { instanceId, onSelect, rootUID, isInline } = this.props;
const { childItems, hoveredItem, suggestedItems, sharedItems, itemsPerCategory, openPanels } = this.state;
const isPanelOpen = ( panel ) => openPanels.indexOf( panel ) !== -1;
@@ -182,6 +183,8 @@ export class InserterMenu extends Component {
/>
+
+
}
+
{ map( getCategories(), ( category ) => {
const categoryItems = itemsPerCategory[ category.slug ];
if ( ! categoryItems || ! categoryItems.length ) {
return null;
}
+
+ if ( isInline && category.slug !== 'inline' ) {
+ return null;
+ }
+
+ if ( ! isInline && category.slug === 'inline' ) {
+ return null;
+ }
+
return (
diff --git a/editor/components/inserter/results-portal.js b/editor/components/inserter/results-portal.js
new file mode 100644
index 00000000000000..62296b8565def5
--- /dev/null
+++ b/editor/components/inserter/results-portal.js
@@ -0,0 +1,27 @@
+/**
+ * WordPress dependencies
+ */
+import { createSlotFill, PanelBody } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import ItemList from './item-list';
+
+const { Fill, Slot } = createSlotFill( 'InserterResultsPortal' );
+
+const InserterResultsPortal = ( { items, title, onSelect } ) => {
+ return (
+
+
+ {} } />
+
+
+ );
+};
+
+InserterResultsPortal.Slot = Slot;
+
+export default InserterResultsPortal;
diff --git a/editor/components/rich-text/core-tokens/image/index.js b/editor/components/rich-text/core-tokens/image/index.js
new file mode 100644
index 00000000000000..e87cf8e911e0d1
--- /dev/null
+++ b/editor/components/rich-text/core-tokens/image/index.js
@@ -0,0 +1,45 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import MediaUpload from '../../../media-upload';
+
+export const name = 'core/image';
+
+export const settings = {
+ id: 'image',
+
+ title: __( 'Inline Image' ),
+
+ type: 'image',
+
+ icon: 'format-image',
+
+ category: 'inline',
+
+ edit( { onSave } ) {
+ return (
+
onSave( media ) }
+ onClose={ () => onSave( null ) }
+ render={ ( { open } ) => {
+ open();
+ return null;
+ } }
+ />
+ );
+ },
+
+ save( { id, url, alt, width } ) {
+ return (
+
+ );
+ },
+};
diff --git a/editor/components/rich-text/core-tokens/index.js b/editor/components/rich-text/core-tokens/index.js
new file mode 100644
index 00000000000000..f940ef02f824a3
--- /dev/null
+++ b/editor/components/rich-text/core-tokens/index.js
@@ -0,0 +1,10 @@
+import { registerToken } from '../tokens/registration';
+import * as image from './image';
+
+export const registerCoreTokens = () => {
+ [
+ image,
+ ].forEach( ( { name, settings } ) => {
+ registerToken( name, settings );
+ } );
+};
diff --git a/editor/components/rich-text/index.js b/editor/components/rich-text/index.js
index 1e84470d24f168..ab2f1359f17122 100644
--- a/editor/components/rich-text/index.js
+++ b/editor/components/rich-text/index.js
@@ -42,6 +42,8 @@ import { pickAriaProps } from './aria';
import patterns from './patterns';
import { withBlockEditContext } from '../block-edit/context';
import { domToFormat, valueToString } from './format';
+import { registerCoreTokens } from './core-tokens';
+import TokenUI from './tokens/ui';
const { BACKSPACE, DELETE, ENTER, rawShortcut } = keycodes;
@@ -889,6 +891,12 @@ export class RichText extends Component {
{ formatToolbar }
) }
+ { isSelected &&
+
+ }
{ ( { isExpanded, listBoxId, activeId } ) => (
@@ -939,6 +947,8 @@ RichText.defaultProps = {
format: 'element',
};
+registerCoreTokens();
+
const RichTextContainer = compose( [
withInstanceId,
withBlockEditContext( ( context, ownProps ) => {
diff --git a/editor/components/rich-text/style.scss b/editor/components/rich-text/style.scss
index 109123c5d6de64..72579e3059a294 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;
+ }
+ }
}
.editor-rich-text__inline-toolbar {
@@ -96,3 +119,11 @@
box-shadow: $shadow-toolbar;
}
}
+
+.blocks-inline-insertion-point {
+ display: block;
+ z-index: 1;
+ width: 4px;
+ margin-left: -2px;
+ background: $blue-medium-500;
+}
diff --git a/editor/components/rich-text/tokens/registration.js b/editor/components/rich-text/tokens/registration.js
new file mode 100644
index 00000000000000..6c3c1aaf0d1333
--- /dev/null
+++ b/editor/components/rich-text/tokens/registration.js
@@ -0,0 +1,150 @@
+/**
+ * External dependencies
+ */
+import { isFunction, has } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { applyFilters } from '@wordpress/hooks';
+
+/**
+ * Browser dependencies
+ */
+const { error } = window.console;
+
+const tokenSettings = {};
+
+/**
+ * Defined behavior of token settings.
+ *
+ * @typedef {WPTokenSettings}
+ *
+ * @property {string} name Token's namespaced name.
+ * @property {string} title Human-readable label for a token.
+ * Shown in the token inserter.
+ * @property {(string|WPElement)} icon Slug of the Dashicon to be shown
+ * as the icon for the token in the
+ * inserter, or element.
+ * @property {?string[]} keywords Additional keywords to produce
+ * block as inserter search result.
+ * @property {Function} save Serialize behavior of a token,
+ * returning an element describing
+ * structure of the token's post
+ * content markup.
+ * @property {WPComponent} edit Component rendering element to be
+ * interacted with in an editor.
+ */
+
+/**
+ * Registers a new token provided a unique name and an object defining its
+ * behavior. Once registered, the token is made available as an option to any
+ * editor interface where tokens are implemented.
+ *
+ * @param {string} name Token name.
+ * @param {WPTokenSettings} settings Token settings.
+ *
+ * @return {?WPTokenSettings} The token settings, if it has been successfully
+ * registered; otherwise `undefined`.
+ */
+export function registerToken( name, settings ) {
+ if ( typeof name !== 'string' ) {
+ error(
+ 'Token names must be strings.'
+ );
+ return;
+ }
+
+ if ( ! /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test( name ) ) {
+ error(
+ 'Token names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-token'
+ );
+ return;
+ }
+
+ if ( getTokenSettings( name ) ) {
+ error(
+ 'Token "' + name + '" is already registered.'
+ );
+ return;
+ }
+
+ settings = applyFilters( 'RichText.registerToken', settings, name );
+
+ if ( ! settings || ! isFunction( settings.save ) ) {
+ error(
+ 'The "save" property must be specified and must be a valid function.'
+ );
+ return;
+ }
+
+ if ( 'edit' in settings && ! isFunction( settings.edit ) ) {
+ error(
+ 'The "edit" property must be a valid function.'
+ );
+ return;
+ }
+
+ if ( 'keywords' in settings && settings.keywords.length > 3 ) {
+ error(
+ 'The token "' + name + '" can have a maximum of 3 keywords.'
+ );
+ return;
+ }
+
+ if ( ! ( 'title' in settings ) || settings.title === '' ) {
+ error(
+ 'The token "' + name + '" must have a title.'
+ );
+ return;
+ }
+
+ if ( typeof settings.title !== 'string' ) {
+ error(
+ 'Token titles must be strings.'
+ );
+ return;
+ }
+
+ if ( ! settings.icon ) {
+ settings.icon = 'block-default';
+ }
+
+ tokenSettings[ name ] = settings;
+
+ return settings;
+}
+
+/**
+ * Unregisters a token.
+ *
+ * @param {string} name Token name.
+ *
+ * @return {?WPTokenSettings} The previous token settings, if it has been
+ * successfully unregistered; otherwise `undefined`.
+ */
+export function unregisterToken( name ) {
+ const settings = getTokenSettings( name );
+
+ if ( settings ) {
+ delete tokenSettings[ name ];
+ return settings;
+ }
+}
+
+/**
+ * Returns registered token settings.
+ *
+ * @param {string} name Token name.
+ *
+ * @return {?WPTokenSettings} Token settings.
+ */
+export function getTokenSettings( name ) {
+ if ( ! name ) {
+ return tokenSettings;
+ }
+
+ if ( has( tokenSettings, name ) ) {
+ return tokenSettings[ name ];
+ }
+}
diff --git a/editor/components/rich-text/tokens/ui.js b/editor/components/rich-text/tokens/ui.js
new file mode 100644
index 00000000000000..746bcc222918ba
--- /dev/null
+++ b/editor/components/rich-text/tokens/ui.js
@@ -0,0 +1,114 @@
+/**
+ * WordPress dependencies
+ */
+import { Component, Fragment, compose, renderToString } from '@wordpress/element';
+import { withSelect, withDispatch } from '@wordpress/data';
+import { withSafeTimeout } from '@wordpress/components';
+import { getRectangleFromRange } from '@wordpress/dom';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { InserterResultsPortal } from '../../inserter';
+import { getTokenSettings } from './registration';
+
+class TokenUI extends Component {
+ constructor() {
+ super( ...arguments );
+
+ this.onSave = this.onSave.bind( this );
+ this.getInsertPosition = this.getInsertPosition.bind( this );
+
+ this.state = {
+ selectedToken: null,
+ };
+ }
+
+ 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 );
+ }
+
+ 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,
+ };
+ }
+
+ onSave( { save } ) {
+ return ( attributes ) => {
+ const { editor } = this.props;
+
+ if ( attributes ) {
+ editor.insertContent( renderToString( save( attributes ) ) );
+ }
+
+ this.setState( { selectedToken: null } );
+ };
+ }
+
+ render() {
+ const { isInlineInsertionPointVisible } = this.props;
+ const { selectedToken } = this.state;
+
+ return (
+
+ { isInlineInsertionPointVisible &&
+
+ }
+ { selectedToken &&
+
+ }
+ this.setState( { selectedToken: settings } ) }
+ />
+
+ );
+ }
+}
+
+export default compose( [
+ withSelect( ( select ) => {
+ const {
+ isInlineInsertionPointVisible,
+ } = select( 'core/editor' );
+
+ return {
+ isInlineInsertionPointVisible: isInlineInsertionPointVisible(),
+ };
+ } ),
+ withDispatch( ( dispatch ) => {
+ const {
+ setInlineInsertAvailable,
+ setInlineInsertUnavailable,
+ } = dispatch( 'core/editor' );
+
+ return {
+ setInsertAvailable: setInlineInsertAvailable,
+ setInsertUnavailable: setInlineInsertUnavailable,
+ };
+ } ),
+ withSafeTimeout,
+] )( TokenUI );
diff --git a/editor/store/actions.js b/editor/store/actions.js
index 24d86603d87b2a..6630208135d3b0 100644
--- a/editor/store/actions.js
+++ b/editor/store/actions.js
@@ -323,6 +323,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 38c3cf6d352a3f..3cf8d355045eb0 100644
--- a/editor/store/reducer.js
+++ b/editor/store/reducer.js
@@ -769,6 +769,48 @@ 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 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.
*
@@ -1090,6 +1132,8 @@ export default optimist( combineReducers( {
blocksMode,
blockListSettings,
isInsertionPointVisible,
+ isInlineInsertionPointVisible,
+ isInlineInsertAvailable,
preferences,
saving,
notices,
diff --git a/editor/store/selectors.js b/editor/store/selectors.js
index 034ce1cc46ae62..3a8c93300cebc0 100644
--- a/editor/store/selectors.js
+++ b/editor/store/selectors.js
@@ -1112,6 +1112,28 @@ 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 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 5972aa9476491f..b5fef9409dbc6a 100644
--- a/editor/store/test/actions.js
+++ b/editor/store/test/actions.js
@@ -26,6 +26,10 @@ import {
insertBlocks,
showInsertionPoint,
hideInsertionPoint,
+ showInlineInsertionPoint,
+ hideInlineInsertionPoint,
+ setInlineInsertAvailable,
+ setInlineInsertUnavailable,
editPost,
savePost,
trashPost,
@@ -228,6 +232,38 @@ describe( 'actions', () => {
} );
} );
+ 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 32aa4e875bfca4..1c2473fff693eb 100644
--- a/editor/store/test/reducer.js
+++ b/editor/store/test/reducer.js
@@ -33,6 +33,8 @@ import {
provisionalBlockUID,
blocksMode,
isInsertionPointVisible,
+ isInlineInsertionPointVisible,
+ isInlineInsertAvailable,
sharedBlocks,
template,
blockListSettings,
@@ -1299,6 +1301,54 @@ 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( '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 ac148b8a958747..e2a48c783593ec 100644
--- a/editor/store/test/selectors.js
+++ b/editor/store/test/selectors.js
@@ -67,6 +67,8 @@ const {
isTyping,
getBlockInsertionPoint,
isBlockInsertionPointVisible,
+ isInlineInsertionPointVisible,
+ isInlineInsertAvailable,
isSavingPost,
didPostSaveRequestSucceed,
didPostSaveRequestFail,
@@ -2578,6 +2580,26 @@ 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( 'isSavingPost', () => {
it( 'should return true if the post is currently being saved', () => {
const state = {
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-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: ![]()