From 8c1b8ec1494965176f3f560d3faac61313835abb Mon Sep 17 00:00:00 2001 From: iseulde Date: Thu, 31 May 2018 15:53:40 +0200 Subject: [PATCH] WIP --- core-blocks/gallery/style.scss | 4 + core-blocks/image/editor.scss | 4 + edit-post/hooks/blocks/media-upload/index.js | 10 ++ editor/components/inserter/index.js | 56 ++++++- editor/components/inserter/menu.js | 19 ++- editor/components/inserter/results-portal.js | 27 ++++ .../rich-text/core-tokens/image/index.js | 45 ++++++ .../components/rich-text/core-tokens/index.js | 10 ++ editor/components/rich-text/index.js | 10 ++ editor/components/rich-text/style.scss | 31 ++++ .../rich-text/tokens/registration.js | 150 ++++++++++++++++++ editor/components/rich-text/tokens/ui.js | 114 +++++++++++++ editor/store/actions.js | 47 ++++++ editor/store/reducer.js | 44 +++++ editor/store/selectors.js | 22 +++ editor/store/test/actions.js | 36 +++++ editor/store/test/reducer.js | 50 ++++++ editor/store/test/selectors.js | 22 +++ .../assets/10x10_e2e_test_image_z9T8jK.png | Bin 0 -> 116 bytes test/e2e/specs/adding-inline-blocks.test.js | 81 ++++++++++ 20 files changed, 774 insertions(+), 8 deletions(-) create mode 100644 editor/components/inserter/results-portal.js create mode 100644 editor/components/rich-text/core-tokens/image/index.js create mode 100644 editor/components/rich-text/core-tokens/index.js create mode 100644 editor/components/rich-text/tokens/registration.js create mode 100644 editor/components/rich-text/tokens/ui.js create mode 100644 test/e2e/assets/10x10_e2e_test_image_z9T8jK.png create mode 100644 test/e2e/specs/adding-inline-blocks.test.js diff --git a/core-blocks/gallery/style.scss b/core-blocks/gallery/style.scss index 9bdaaafc9105b..4a3e7a6cb19ba 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 be7a784f71ee7..0aebda1dacc8e 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 fe20dad65ec2e..59d2c115f363b 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 9eeca66c55fd3..373980a80b035 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 010229da16d86..c658a0d6c6f3a 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 0000000000000..62296b8565def --- /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 0000000000000..e87cf8e911e0d --- /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 0000000000000..f940ef02f824a --- /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 1e84470d24f16..ab2f1359f1712 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 109123c5d6de6..72579e3059a29 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 0000000000000..6c3c1aaf0d133 --- /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 0000000000000..746bcc222918b --- /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 24d86603d87b2..6630208135d3b 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 38c3cf6d352a3..3cf8d355045eb 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 034ce1cc46ae6..3a8c93300cebc 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 5972aa9476491..b5fef9409dbc6 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 32aa4e875bfca..1c2473fff693e 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 ac148b8a95874..e2a48c783593e 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 0000000000000000000000000000000000000000..4d198c0023578e82e80d798e3d4f34a742edd749 GIT binary patch literal 116 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2xGmzZ=C-xtZVhivIab@`bABgxBvV4IeoCO|{ z#S9F5M?jcysy3fAP*B9v#W93qW^#f98!rzZuYypE0|SH9JSNAD@4kREF?hQAxvX { + 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: