diff --git a/packages/api-fetch/src/middlewares/preloading.js b/packages/api-fetch/src/middlewares/preloading.js index 020cb913fced15..4de356243b7366 100644 --- a/packages/api-fetch/src/middlewares/preloading.js +++ b/packages/api-fetch/src/middlewares/preloading.js @@ -34,7 +34,11 @@ const createPreloadingMiddleware = ( preloadedData ) => ( options, next ) => { if ( parse && 'GET' === method && preloadedData[ path ] ) { return Promise.resolve( preloadedData[ path ].body ); - } else if ( 'OPTIONS' === method && preloadedData[ method ][ path ] ) { + } else if ( + 'OPTIONS' === method && + preloadedData[ method ] && + preloadedData[ method ][ path ] + ) { return Promise.resolve( preloadedData[ method ][ path ] ); } } diff --git a/packages/api-fetch/src/middlewares/test/preloading.js b/packages/api-fetch/src/middlewares/test/preloading.js index d697af3b429494..e2d8a1f4d0c68d 100644 --- a/packages/api-fetch/src/middlewares/test/preloading.js +++ b/packages/api-fetch/src/middlewares/test/preloading.js @@ -25,20 +25,29 @@ describe( 'Preloading Middleware', () => { } ); } ); - it( 'should move to the next middleware if no preloaded data', () => { - const preloadedData = {}; - const prelooadingMiddleware = createPreloadingMiddleware( preloadedData ); - const requestOptions = { - method: 'GET', - path: 'wp/v2/posts', - }; + describe.each( [ + [ 'GET' ], + [ 'OPTIONS' ], + ] )( '%s', ( method ) => { + describe.each( [ + [ 'all empty', {} ], + [ 'method empty', { [ method ]: {} } ], + ] )( '%s', ( label, preloadedData ) => { + it( 'should move to the next middleware if no preloaded data', () => { + const prelooadingMiddleware = createPreloadingMiddleware( preloadedData ); + const requestOptions = { + method, + path: 'wp/v2/posts', + }; - const callback = ( options ) => { - expect( options ).toBe( requestOptions ); - return true; - }; + const callback = ( options ) => { + expect( options ).toBe( requestOptions ); + return true; + }; - const ret = prelooadingMiddleware( requestOptions, callback ); - expect( ret ).toBe( true ); + const ret = prelooadingMiddleware( requestOptions, callback ); + expect( ret ).toBe( true ); + } ); + } ); } ); } ); diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index 5c4cae61450ac4..4306b855955f52 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.0.0 (Unreleased) + +### Breaking Changes + +- `CopyHandler` will now only catch cut/copy events coming from its `props.children`, instead of from anywhere in the `document`. + ## 1.0.0 (2019-03-06) ### New Features diff --git a/packages/block-editor/src/components/block-list/style.scss b/packages/block-editor/src/components/block-list/style.scss index 4a72cb3ed3423d..5f48fe5a165dba 100644 --- a/packages/block-editor/src/components/block-list/style.scss +++ b/packages/block-editor/src/components/block-list/style.scss @@ -904,6 +904,12 @@ .block-editor-block-contextual-toolbar > * { pointer-events: auto; } + + // Full-aligned blocks have negative margins on the parent of the toolbar, so additional position adjustment is not required. + &[data-align="full"] .block-editor-block-contextual-toolbar { + left: 0; + right: 0; + } } .block-editor-block-list__block.is-focus-mode:not(.is-multi-selected) > .block-editor-block-contextual-toolbar { diff --git a/packages/block-editor/src/components/block-settings-menu/block-convert-button.js b/packages/block-editor/src/components/block-settings-menu/block-convert-button.js index 2ae65fed66cfd9..f27cac38b1df9a 100644 --- a/packages/block-editor/src/components/block-settings-menu/block-convert-button.js +++ b/packages/block-editor/src/components/block-settings-menu/block-convert-button.js @@ -15,7 +15,6 @@ export default function BlockConvertButton( { shouldRender, onClick, small } ) { className="editor-block-settings-menu__control block-editor-block-settings-menu__control" onClick={ onClick } icon="screenoptions" - label={ small ? label : undefined } > { ! small && label } </MenuItem> diff --git a/packages/block-editor/src/components/block-settings-menu/block-mode-toggle.js b/packages/block-editor/src/components/block-settings-menu/block-mode-toggle.js index 8ba029fd1794c1..3f8260d135e441 100644 --- a/packages/block-editor/src/components/block-settings-menu/block-mode-toggle.js +++ b/packages/block-editor/src/components/block-settings-menu/block-mode-toggle.js @@ -26,7 +26,6 @@ export function BlockModeToggle( { blockType, mode, onToggleMode, small = false className="editor-block-settings-menu__control block-editor-block-settings-menu__control" onClick={ onToggleMode } icon="html" - label={ small ? label : undefined } > { ! small && label } </MenuItem> diff --git a/packages/block-editor/src/components/copy-handler/index.js b/packages/block-editor/src/components/copy-handler/index.js index 3942eaae71b034..3f73d5f672c153 100644 --- a/packages/block-editor/src/components/copy-handler/index.js +++ b/packages/block-editor/src/components/copy-handler/index.js @@ -1,33 +1,17 @@ /** * WordPress dependencies */ -import { Component } from '@wordpress/element'; import { serialize } from '@wordpress/blocks'; import { documentHasSelection } from '@wordpress/dom'; import { withDispatch } from '@wordpress/data'; import { compose } from '@wordpress/compose'; -class CopyHandler extends Component { - constructor() { - super( ...arguments ); - - this.onCopy = ( event ) => this.props.onCopy( event ); - this.onCut = ( event ) => this.props.onCut( event ); - } - - componentDidMount() { - document.addEventListener( 'copy', this.onCopy ); - document.addEventListener( 'cut', this.onCut ); - } - - componentWillUnmount() { - document.removeEventListener( 'copy', this.onCopy ); - document.removeEventListener( 'cut', this.onCut ); - } - - render() { - return null; - } +function CopyHandler( { children, onCopy, onCut } ) { + return ( + <div onCopy={ onCopy } onCut={ onCut }> + { children } + </div> + ); } export default compose( [ diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index 006ec4a697760b..c6cfad310c89c6 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -42,6 +42,8 @@ import { isCollapsed, LINE_SEPARATOR, indentListItems, + __unstableGetActiveFormats, + __unstableUpdateFormats, } from '@wordpress/rich-text'; import { decodeEntities } from '@wordpress/html-entities'; import { withFilters, IsolatedEventContainer } from '@wordpress/components'; @@ -82,6 +84,13 @@ const INSERTION_INPUT_TYPES_TO_IGNORE = new Set( [ 'insertLink', ] ); +/** + * Global stylesheet. + */ +const globalStyle = document.createElement( 'style' ); + +document.head.appendChild( globalStyle ); + export class RichText extends Component { constructor( { value, onReplace, multiline } ) { super( ...arguments ); @@ -126,7 +135,10 @@ export class RichText extends Component { this.handleHorizontalNavigation = this.handleHorizontalNavigation.bind( this ); this.onPointerDown = this.onPointerDown.bind( this ); - this.formatToValue = memize( this.formatToValue.bind( this ), { size: 1 } ); + this.formatToValue = memize( + this.formatToValue.bind( this ), + { maxSize: 1 } + ); this.savedContent = value; this.patterns = getPatterns( { @@ -176,9 +188,9 @@ export class RichText extends Component { */ getRecord() { const { formats, replacements, text } = this.formatToValue( this.props.value ); - const { start, end, selectedFormat } = this.state; + const { start, end, activeFormats } = this.state; - return { formats, replacements, text, start, end, selectedFormat }; + return { formats, replacements, text, start, end, activeFormats }; } createRecord() { @@ -355,6 +367,8 @@ export class RichText extends Component { unstableOnFocus(); } + this.recalculateBoundaryStyle(); + document.addEventListener( 'selectionchange', this.onSelectionChange ); } @@ -393,40 +407,19 @@ export class RichText extends Component { } } - let { selectedFormat } = this.state; - const { formats, replacements, text, start, end } = this.createRecord(); - - if ( this.formatPlaceholder ) { - selectedFormat = this.formatPlaceholder.length; - - if ( selectedFormat > 0 ) { - formats[ this.state.start ] = this.formatPlaceholder; - } else { - delete formats[ this.state.start ]; - } - } else if ( selectedFormat > 0 ) { - const formatsBefore = formats[ start - 1 ] || []; - const formatsAfter = formats[ start ] || []; - - let source = formatsBefore; - - if ( formatsAfter.length > formatsBefore.length ) { - source = formatsAfter; - } - - source = source.slice( 0, selectedFormat ); - - formats[ this.state.start ] = source; - } else { - delete formats[ this.state.start ]; - } - - const change = { formats, replacements, text, start, end, selectedFormat }; + const value = this.createRecord(); + const { activeFormats = [], start } = this.state; - this.onChange( change, { - withoutHistory: true, + // Update the formats between the last and new caret position. + const change = __unstableUpdateFormats( { + value, + start, + end: value.start, + formats: activeFormats, } ); + this.onChange( change, { withoutHistory: true } ); + const transformed = this.patterns.reduce( ( accumlator, transform ) => transform( accumlator ), change @@ -434,7 +427,7 @@ export class RichText extends Component { if ( transformed !== change ) { this.onCreateUndoLevel(); - this.onChange( { ...transformed, selectedFormat } ); + this.onChange( { ...transformed, activeFormats } ); } // Create an undo level when input stops for over a second. @@ -454,36 +447,40 @@ export class RichText extends Component { * Handles the `selectionchange` event: sync the selection to local state. */ onSelectionChange() { - if ( this.ignoreSelectionChange ) { - delete this.ignoreSelectionChange; - return; - } - const value = this.createRecord(); - const { start, end, formats } = value; + const { start, end } = value; if ( start !== this.state.start || end !== this.state.end ) { - const isCaretWithinFormattedText = this.props.isCaretWithinFormattedText; + const { isCaretWithinFormattedText } = this.props; + const activeFormats = __unstableGetActiveFormats( value ); - if ( ! isCaretWithinFormattedText && formats[ start ] ) { + if ( ! isCaretWithinFormattedText && activeFormats.length ) { this.props.onEnterFormattedText(); - } else if ( isCaretWithinFormattedText && ! formats[ start ] ) { + } else if ( isCaretWithinFormattedText && ! activeFormats.length ) { this.props.onExitFormattedText(); } - let selectedFormat; - - if ( isCollapsed( value ) ) { - const formatsBefore = formats[ start - 1 ] || []; - const formatsAfter = formats[ start ] || []; + this.setState( { start, end, activeFormats } ); + this.applyRecord( { ...value, activeFormats }, { domOnly: true } ); - selectedFormat = Math.min( formatsBefore.length, formatsAfter.length ); + if ( activeFormats.length > 0 ) { + this.recalculateBoundaryStyle(); } + } + } + + recalculateBoundaryStyle() { + const boundarySelector = '*[data-rich-text-format-boundary]'; + const element = this.editableRef.querySelector( boundarySelector ); - this.setState( { start, end, selectedFormat } ); - this.applyRecord( { ...value, selectedFormat }, { domOnly: true } ); + if ( element ) { + const computedStyle = getComputedStyle( element ); + const newColor = computedStyle.color + .replace( ')', ', 0.2)' ) + .replace( 'rgb', 'rgba' ); - delete this.formatPlaceholder; + globalStyle.innerHTML = + `*:focus ${ boundarySelector }{background-color: ${ newColor }}`; } } @@ -511,14 +508,12 @@ export class RichText extends Component { onChange( record, { withoutHistory } = {} ) { this.applyRecord( record ); - const { start, end, formatPlaceholder, selectedFormat } = record; + const { start, end, activeFormats = [] } = record; - this.formatPlaceholder = formatPlaceholder; this.onChangeEditableValue( record ); - this.savedContent = this.valueToFormat( record ); this.props.onChange( this.savedContent ); - this.setState( { start, end, selectedFormat } ); + this.setState( { start, end, activeFormats } ); if ( ! withoutHistory ) { this.onCreateUndoLevel(); @@ -734,17 +729,15 @@ export class RichText extends Component { handleHorizontalNavigation( event ) { const value = this.createRecord(); const { formats, text, start, end } = value; - const { selectedFormat } = this.state; + const { activeFormats = [] } = this.state; const collapsed = isCollapsed( value ); const isReverse = event.keyCode === LEFT; - delete this.formatPlaceholder; - // If the selection is collapsed and at the very start, do nothing if // navigating backward. // If the selection is collapsed and at the very end, do nothing if // navigating forward. - if ( collapsed && selectedFormat === 0 ) { + if ( collapsed && activeFormats.length === 0 ) { if ( start === 0 && isReverse ) { return; } @@ -764,38 +757,43 @@ export class RichText extends Component { // In all other cases, prevent default behaviour. event.preventDefault(); - // Ignore the selection change handler when setting selection, all state - // will be set here. - this.ignoreSelectionChange = true; - const formatsBefore = formats[ start - 1 ] || []; const formatsAfter = formats[ start ] || []; - let newSelectedFormat = selectedFormat; + let newActiveFormatsLength = activeFormats.length; + let source = formatsAfter; + + if ( formatsBefore.length > formatsAfter.length ) { + source = formatsBefore; + } // If the amount of formats before the caret and after the caret is // different, the caret is at a format boundary. if ( formatsBefore.length < formatsAfter.length ) { - if ( ! isReverse && selectedFormat < formatsAfter.length ) { - newSelectedFormat++; + if ( ! isReverse && activeFormats.length < formatsAfter.length ) { + newActiveFormatsLength++; } - if ( isReverse && selectedFormat > formatsBefore.length ) { - newSelectedFormat--; + if ( isReverse && activeFormats.length > formatsBefore.length ) { + newActiveFormatsLength--; } } else if ( formatsBefore.length > formatsAfter.length ) { - if ( ! isReverse && selectedFormat > formatsAfter.length ) { - newSelectedFormat--; + if ( ! isReverse && activeFormats.length > formatsAfter.length ) { + newActiveFormatsLength--; } - if ( isReverse && selectedFormat < formatsBefore.length ) { - newSelectedFormat++; + if ( isReverse && activeFormats.length < formatsBefore.length ) { + newActiveFormatsLength++; } } - if ( newSelectedFormat !== selectedFormat ) { - this.applyRecord( { ...value, selectedFormat: newSelectedFormat } ); - this.setState( { selectedFormat: newSelectedFormat } ); + // Wait for boundary class to be added. + setTimeout( () => this.recalculateBoundaryStyle() ); + + if ( newActiveFormatsLength !== activeFormats.length ) { + const newActiveFormats = source.slice( 0, newActiveFormatsLength ); + this.applyRecord( { ...value, activeFormats: newActiveFormats } ); + this.setState( { activeFormats: newActiveFormats } ); return; } @@ -806,7 +804,7 @@ export class RichText extends Component { ...value, start: newPos, end: newPos, - selectedFormat: isReverse ? formatsBefore.length : formatsAfter.length, + activeFormats: isReverse ? formatsBefore : formatsAfter, } ); } diff --git a/packages/block-editor/src/components/rich-text/style.scss b/packages/block-editor/src/components/rich-text/style.scss index 16fdc282020e0c..4a52ddb56fcd37 100644 --- a/packages/block-editor/src/components/rich-text/style.scss +++ b/packages/block-editor/src/components/rich-text/style.scss @@ -45,27 +45,6 @@ *[data-rich-text-format-boundary] { border-radius: 2px; - box-shadow: 0 0 0 1px $light-gray-400; - background: $light-gray-400; - - // Enforce a dark text color so active inline boundaries - // are always readable. - // See https://github.com/WordPress/gutenberg/issues/9508 - color: $dark-gray-900; - } - - // Link inline boundaries get special colors. - a[data-rich-text-format-boundary] { - box-shadow: 0 0 0 1px $blue-medium-100; - background: $blue-medium-100; - color: $blue-medium-900; - } - - // <code> inline boundaries need special treatment because their - // un-selected style is already padded. - code[data-rich-text-format-boundary] { - background: $light-gray-400; - box-shadow: 0 0 0 1px $light-gray-400; } } diff --git a/packages/block-library/src/cover/editor.scss b/packages/block-library/src/cover/editor.scss index c74979dc0969d4..b368b3ee24c05d 100644 --- a/packages/block-library/src/cover/editor.scss +++ b/packages/block-library/src/cover/editor.scss @@ -1,10 +1,5 @@ .wp-block-cover-image, .wp-block-cover { - .block-editor-rich-text__editable:focus a[data-rich-text-format-boundary] { - box-shadow: none; - background: rgba(255, 255, 255, 0.3); - } - &.components-placeholder h2 { color: inherit; } diff --git a/packages/block-library/src/embed/edit.js b/packages/block-library/src/embed/edit.js index 0e251a8f0aafd0..fcc305d9e8a7c3 100644 --- a/packages/block-library/src/embed/edit.js +++ b/packages/block-library/src/embed/edit.js @@ -61,13 +61,24 @@ export function getEmbedEditComponent( title, icon, responsive = true ) { if ( switchedPreview || switchedURL ) { if ( this.props.cannotEmbed ) { - // Can't embed this URL, and we've just received or switched the preview. + // We either have a new preview or a new URL, but we can't embed it. + if ( ! this.props.fetching ) { + // If we're not fetching the preview, then we know it can't be embedded, so try + // removing any trailing slash, and resubmit. + this.resubmitWithoutTrailingSlash(); + } return; } this.handleIncomingPreview(); } } + resubmitWithoutTrailingSlash() { + this.setState( ( prevState ) => ( { + url: prevState.url.replace( /\/$/, '' ), + } ), this.setUrl ); + } + setUrl( event ) { if ( event ) { event.preventDefault(); diff --git a/packages/block-library/src/embed/util.js b/packages/block-library/src/embed/util.js index 085ea4b22ff8c8..9cba9f50364745 100644 --- a/packages/block-library/src/embed/util.js +++ b/packages/block-library/src/embed/util.js @@ -46,7 +46,7 @@ export const findBlock = ( url ) => { }; export const isFromWordPress = ( html ) => { - return includes( html, 'class="wp-embedded-content" data-secret' ); + return includes( html, 'class="wp-embedded-content"' ); }; export const getPhotoHtml = ( photo ) => { diff --git a/packages/block-library/src/gallery/editor.scss b/packages/block-library/src/gallery/editor.scss index 0340f66a31e8e5..34b08797e2e558 100644 --- a/packages/block-library/src/gallery/editor.scss +++ b/packages/block-library/src/gallery/editor.scss @@ -82,10 +82,6 @@ ul.wp-block-gallery li { a { color: $white; } - - &:focus a[data-rich-text-format-boundary] { - color: rgba(0, 0, 0, 0.2); - } } } diff --git a/packages/block-library/src/preformatted/index.js b/packages/block-library/src/preformatted/index.js index 7d90224f9639cd..9553ac77885f98 100644 --- a/packages/block-library/src/preformatted/index.js +++ b/packages/block-library/src/preformatted/index.js @@ -68,10 +68,14 @@ export const settings = { return ( <RichText tagName="pre" + // Ensure line breaks are normalised to HTML. value={ content.replace( /\n/g, '<br>' ) } onChange={ ( nextContent ) => { setAttributes( { - content: nextContent, + // Ensure line breaks are normalised to characters. This + // saves space, is easier to read, and ensures display + // filters work correctly. + content: nextContent.replace( /<br ?\/?>/g, '\n' ), } ); } } placeholder={ __( 'Write preformatted text…' ) } diff --git a/packages/blocks/src/api/raw-handling/paste-handler.js b/packages/blocks/src/api/raw-handling/paste-handler.js index b40164f549689b..1d67540c0b9225 100644 --- a/packages/blocks/src/api/raw-handling/paste-handler.js +++ b/packages/blocks/src/api/raw-handling/paste-handler.js @@ -127,9 +127,14 @@ export function pasteHandler( { HTML = '', plainText = '', mode = 'AUTO', tagNam // First of all, strip any meta tags. HTML = HTML.replace( /<meta[^>]+>/, '' ); - // If we detect block delimiters, parse entirely as blocks. - if ( mode !== 'INLINE' && HTML.indexOf( '<!-- wp:' ) !== -1 ) { - return parseWithGrammar( HTML ); + // If we detect block delimiters in HTML, parse entirely as blocks. + if ( mode !== 'INLINE' ) { + // Check plain text if there is no HTML. + const content = HTML ? HTML : plainText; + + if ( content.indexOf( '<!-- wp:' ) !== -1 ) { + return parseWithGrammar( content ); + } } // Normalize unicode to use composed characters. diff --git a/packages/components/src/menu-item/README.md b/packages/components/src/menu-item/README.md index eacd6770f7aaf3..38251ccedf2614 100644 --- a/packages/components/src/menu-item/README.md +++ b/packages/components/src/menu-item/README.md @@ -34,15 +34,6 @@ Element to render as child of button. Element -### `label` - -- Type: `string` -- Required: No - -String to use as primary button label text, applied as `aria-label`. Useful in cases where an `info` prop is passed, where `label` should be the minimal text of the button, described in further detail by `info`. - -Defaults to the value of `children`, if `children` is passed as a string. - ### `info` - Type: `string` diff --git a/packages/components/src/menu-item/index.js b/packages/components/src/menu-item/index.js index 860430b4ecb67c..1b793a2c5cf265 100644 --- a/packages/components/src/menu-item/index.js +++ b/packages/components/src/menu-item/index.js @@ -30,7 +30,6 @@ export function MenuItem( { shortcut, isSelected, role = 'menuitem', - instanceId, ...props } ) { className = classnames( 'components-menu-item__button', className, { @@ -38,16 +37,10 @@ export function MenuItem( { } ); if ( info ) { - const infoId = 'edit-post-feature-toggle__info-' + instanceId; - - // Deconstructed props is scoped to the function; mutation is fine. - props[ 'aria-describedby' ] = infoId; - children = ( <span className="components-menu-item__info-wrapper"> { children } <span - id={ infoId } className="components-menu-item__info"> { info } </span> diff --git a/packages/components/src/menu-item/test/__snapshots__/index.js.snap b/packages/components/src/menu-item/test/__snapshots__/index.js.snap index a0f00b2889f008..d029c29c0cb3aa 100644 --- a/packages/components/src/menu-item/test/__snapshots__/index.js.snap +++ b/packages/components/src/menu-item/test/__snapshots__/index.js.snap @@ -18,7 +18,6 @@ exports[`MenuItem should match snapshot when all props provided 1`] = ` exports[`MenuItem should match snapshot when info is provided 1`] = ` <ForwardRef(Button) - aria-describedby="edit-post-feature-toggle__info-1" className="components-menu-item__button" role="menuitem" > @@ -28,7 +27,6 @@ exports[`MenuItem should match snapshot when info is provided 1`] = ` My item <span className="components-menu-item__info" - id="edit-post-feature-toggle__info-1" > Extended description of My Item </span> diff --git a/packages/components/src/menu-item/test/index.js b/packages/components/src/menu-item/test/index.js index f521add7452c86..63ac41b337dff9 100644 --- a/packages/components/src/menu-item/test/index.js +++ b/packages/components/src/menu-item/test/index.js @@ -54,7 +54,7 @@ describe( 'MenuItem', () => { it( 'should match snapshot when info is provided', () => { const wrapper = shallow( - <MenuItem info="Extended description of My Item" instanceId={ 1 }> + <MenuItem info="Extended description of My Item"> My item </MenuItem> ); diff --git a/packages/dom/README.md b/packages/dom/README.md index bf572a4741b508..c6b99455456023 100644 --- a/packages/dom/README.md +++ b/packages/dom/README.md @@ -170,7 +170,7 @@ Check whether the selection is vertically at the edge of the container. **Returns** -`boolean`: True if at the edge, false if not. +`boolean`: True if at the vertical edge, false if not. ### placeCaretAtHorizontalEdge diff --git a/packages/dom/src/dom.js b/packages/dom/src/dom.js index 4ea9ad8c0ef78f..87be698a3be8d0 100644 --- a/packages/dom/src/dom.js +++ b/packages/dom/src/dom.js @@ -60,14 +60,17 @@ function isSelectionForward( selection ) { } /** - * Check whether the selection is horizontally at the edge of the container. + * Check whether the selection is at the edge of the container. Checks for + * horizontal position by default. Set `onlyVertical` to true to check only + * vertically. * - * @param {Element} container Focusable element. - * @param {boolean} isReverse Set to true to check left, false for right. + * @param {Element} container Focusable element. + * @param {boolean} isReverse Set to true to check left, false to check right. + * @param {boolean} onlyVertical Set to true to check only vertical position. * - * @return {boolean} True if at the horizontal edge, false if not. + * @return {boolean} True if at the edge, false if not. */ -export function isHorizontalEdge( container, isReverse ) { +function isEdge( container, isReverse, onlyVertical ) { if ( includes( [ 'INPUT', 'TEXTAREA' ], container.tagName ) ) { if ( container.selectionStart !== container.selectionEnd ) { return false; @@ -86,131 +89,88 @@ export function isHorizontalEdge( container, isReverse ) { const selection = window.getSelection(); - // Create copy of range for setting selection to find effective offset. - const range = selection.getRangeAt( 0 ).cloneRange(); - - // Collapse in direction of selection. - if ( ! selection.isCollapsed ) { - range.collapse( ! isSelectionForward( selection ) ); - } - - let node = range.startContainer; - - let extentOffset; - if ( isReverse ) { - // When in reverse, range node should be first. - extentOffset = 0; - } else if ( node.nodeValue ) { - // Otherwise, vary by node type. A text node has no children. Its range - // offset reflects its position in nodeValue. - // - // "If the startContainer is a Node of type Text, Comment, or - // CDATASection, then the offset is the number of characters from the - // start of the startContainer to the boundary point of the Range." - // - // See: https://developer.mozilla.org/en-US/docs/Web/API/Range/startOffset - // See: https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeValue - extentOffset = node.nodeValue.length; - } else { - // "For other Node types, the startOffset is the number of child nodes - // between the start of the startContainer and the boundary point of - // the Range." - // - // See: https://developer.mozilla.org/en-US/docs/Web/API/Range/startOffset - extentOffset = node.childNodes.length; - } - - // Offset of range should be at expected extent. - const position = isReverse ? 'start' : 'end'; - const offset = range[ `${ position }Offset` ]; - if ( offset !== extentOffset ) { + if ( ! selection.rangeCount ) { return false; } - // If confirmed to be at extent, traverse up through DOM, verifying that - // the node is at first or last child for reverse or forward respectively - // (ignoring empty text nodes). Continue until container is reached. - const order = isReverse ? 'previous' : 'next'; - - while ( node !== container ) { - let next = node[ `${ order }Sibling` ]; - - // Skip over empty text nodes. - while ( next && next.nodeType === TEXT_NODE && next.data === '' ) { - next = next[ `${ order }Sibling` ]; - } - - if ( next ) { - return false; - } - - node = node.parentNode; - } - - // If reached, range is assumed to be at edge. - return true; -} - -/** - * Check whether the selection is vertically at the edge of the container. - * - * @param {Element} container Focusable element. - * @param {boolean} isReverse Set to true to check top, false for bottom. - * - * @return {boolean} True if at the edge, false if not. - */ -export function isVerticalEdge( container, isReverse ) { - if ( includes( [ 'INPUT', 'TEXTAREA' ], container.tagName ) ) { - return isHorizontalEdge( container, isReverse ); - } + const rangeRect = getRectangleFromRange( selection.getRangeAt( 0 ) ); - if ( ! container.isContentEditable ) { - return true; + if ( ! rangeRect ) { + return false; } - const selection = window.getSelection(); + const computedStyle = window.getComputedStyle( container ); + const lineHeight = parseInt( computedStyle.lineHeight, 10 ); - // Only consider the selection at the edge if the direction is towards the - // edge. + // Only consider the multiline selection at the edge if the direction is + // towards the edge. if ( ! selection.isCollapsed && + rangeRect.height > lineHeight && isSelectionForward( selection ) === isReverse ) { return false; } - const range = selection.rangeCount ? selection.getRangeAt( 0 ) : null; + // Calculate a buffer that is half the line height. In some browsers, the + // selection rectangle may not fill the entire height of the line, so we add + // 3/4 the line height to the selection rectangle to ensure that it is well + // over its line boundary. + const buffer = 3 * parseInt( lineHeight, 10 ) / 4; + const containerRect = container.getBoundingClientRect(); + const verticalEdge = isReverse ? + containerRect.top > rangeRect.top - buffer : + containerRect.bottom < rangeRect.bottom + buffer; - if ( ! range ) { + if ( ! verticalEdge ) { return false; } - const rangeRect = getRectangleFromRange( range ); - - if ( ! rangeRect ) { - return false; + if ( onlyVertical ) { + return true; } - const editableRect = container.getBoundingClientRect(); - - // Calculate a buffer that is half the line height. In some browsers, the - // selection rectangle may not fill the entire height of the line, so we add - // half the line height to the selection rectangle to ensure that it is well - // over its line boundary. - const { lineHeight } = window.getComputedStyle( container ); - const buffer = parseInt( lineHeight, 10 ) / 2; + // To calculate the horizontal position, we insert a test range and see if + // this test range has the same horizontal position. This method proves to + // be better than a DOM-based calculation, because it ignores empty text + // nodes and a trailing line break element. In other words, we need to check + // visual positioning, not DOM positioning. + const x = isReverse ? containerRect.left + 1 : containerRect.right - 1; + const y = isReverse ? containerRect.top + buffer : containerRect.bottom - buffer; + const testRange = hiddenCaretRangeFromPoint( document, x, y, container ); - // Too low. - if ( isReverse && rangeRect.top - buffer > editableRect.top ) { + if ( ! testRange ) { return false; } - // Too high. - if ( ! isReverse && rangeRect.bottom + buffer < editableRect.bottom ) { - return false; - } + const side = isReverse ? 'left' : 'right'; + const testRect = getRectangleFromRange( testRange ); - return true; + return Math.round( testRect[ side ] ) === Math.round( rangeRect[ side ] ); +} + +/** + * Check whether the selection is horizontally at the edge of the container. + * + * @param {Element} container Focusable element. + * @param {boolean} isReverse Set to true to check left, false for right. + * + * @return {boolean} True if at the horizontal edge, false if not. + */ +export function isHorizontalEdge( container, isReverse ) { + return isEdge( container, isReverse ); +} + +/** + * Check whether the selection is vertically at the edge of the container. + * + * @param {Element} container Focusable element. + * @param {boolean} isReverse Set to true to check top, false for bottom. + * + * @return {boolean} True if at the vertical edge, false if not. + */ +export function isVerticalEdge( container, isReverse ) { + return isEdge( container, isReverse, true ); } /** @@ -228,6 +188,18 @@ export function getRectangleFromRange( range ) { return range.getBoundingClientRect(); } + const { startContainer } = range; + + // Correct invalid "BR" ranges. The cannot contain any children. + if ( startContainer.nodeName === 'BR' ) { + const { parentNode } = startContainer; + const index = Array.from( parentNode.childNodes ).indexOf( startContainer ); + + range = document.createRange(); + range.setStart( parentNode, index ); + range.setEnd( parentNode, index ); + } + let rect = range.getClientRects()[ 0 ]; // If the collapsed range starts (and therefore ends) at an element node, diff --git a/packages/e2e-tests/specs/__snapshots__/adding-blocks.test.js.snap b/packages/e2e-tests/specs/__snapshots__/adding-blocks.test.js.snap index 30b83349a7030a..b8160cb3842efc 100644 --- a/packages/e2e-tests/specs/__snapshots__/adding-blocks.test.js.snap +++ b/packages/e2e-tests/specs/__snapshots__/adding-blocks.test.js.snap @@ -42,7 +42,9 @@ exports[`adding blocks Should insert content using the placeholder and the regul <!-- /wp:quote --> <!-- wp:preformatted --> -<pre class=\\"wp-block-preformatted\\">Pre text<br><br>Foo</pre> +<pre class=\\"wp-block-preformatted\\">Pre text + +Foo</pre> <!-- /wp:preformatted --> <!-- wp:shortcode --> diff --git a/packages/e2e-tests/specs/__snapshots__/multi-block-selection.test.js.snap b/packages/e2e-tests/specs/__snapshots__/multi-block-selection.test.js.snap index 6986ffc94968bc..f68e40f606dc4c 100644 --- a/packages/e2e-tests/specs/__snapshots__/multi-block-selection.test.js.snap +++ b/packages/e2e-tests/specs/__snapshots__/multi-block-selection.test.js.snap @@ -6,6 +6,8 @@ exports[`Multi-block selection should allow selecting outer edge if there is no <!-- /wp:paragraph -->" `; +exports[`Multi-block selection should always expand single line selection 1`] = `""`; + exports[`Multi-block selection should only trigger multi-selection when at the end 1`] = ` "<!-- wp:paragraph --> <p>1.</p> diff --git a/packages/e2e-tests/specs/__snapshots__/rich-text.test.js.snap b/packages/e2e-tests/specs/__snapshots__/rich-text.test.js.snap index f5c79ddbd33ac5..bf5fefd31a7e32 100644 --- a/packages/e2e-tests/specs/__snapshots__/rich-text.test.js.snap +++ b/packages/e2e-tests/specs/__snapshots__/rich-text.test.js.snap @@ -18,6 +18,12 @@ exports[`RichText should apply formatting with primary shortcut 1`] = ` <!-- /wp:paragraph -->" `; +exports[`RichText should apply multiple formats when selection is collapsed 1`] = ` +"<!-- wp:paragraph --> +<p><strong><em>1</em></strong>.</p> +<!-- /wp:paragraph -->" +`; + exports[`RichText should handle change in tag name gracefully 1`] = ` "<!-- wp:heading {\\"level\\":3} --> <h3></h3> diff --git a/packages/e2e-tests/specs/__snapshots__/writing-flow.test.js.snap b/packages/e2e-tests/specs/__snapshots__/writing-flow.test.js.snap index 41f6fe04a2b531..2a80f20633a953 100644 --- a/packages/e2e-tests/specs/__snapshots__/writing-flow.test.js.snap +++ b/packages/e2e-tests/specs/__snapshots__/writing-flow.test.js.snap @@ -126,6 +126,16 @@ exports[`adding blocks should navigate around nested inline boundaries 2`] = ` <!-- /wp:paragraph -->" `; +exports[`adding blocks should navigate empty paragraph 1`] = ` +"<!-- wp:paragraph --> +<p>1</p> +<!-- /wp:paragraph --> + +<!-- wp:paragraph --> +<p>2</p> +<!-- /wp:paragraph -->" +`; + exports[`adding blocks should not create extra line breaks in multiline value 1`] = ` "<!-- wp:quote --> <blockquote class=\\"wp-block-quote\\"><p></p></blockquote> diff --git a/packages/e2e-tests/specs/blocks/__snapshots__/preformatted.test.js.snap b/packages/e2e-tests/specs/blocks/__snapshots__/preformatted.test.js.snap index 4538816e15b411..07a70a19a6b2be 100644 --- a/packages/e2e-tests/specs/blocks/__snapshots__/preformatted.test.js.snap +++ b/packages/e2e-tests/specs/blocks/__snapshots__/preformatted.test.js.snap @@ -9,6 +9,8 @@ exports[`Preformatted should preserve character newlines 1`] = ` exports[`Preformatted should preserve character newlines 2`] = ` "<!-- wp:preformatted --> -<pre class=\\"wp-block-preformatted\\">0<br>1<br>2</pre> +<pre class=\\"wp-block-preformatted\\">0 +1 +2</pre> <!-- /wp:preformatted -->" `; diff --git a/packages/e2e-tests/specs/embedding.test.js b/packages/e2e-tests/specs/embedding.test.js index 3ea51358b14b21..6fb26c02281c70 100644 --- a/packages/e2e-tests/specs/embedding.test.js +++ b/packages/e2e-tests/specs/embedding.test.js @@ -9,6 +9,8 @@ import { createJSONResponse, getEditedPostContent, clickButton, + insertBlock, + publishPost, } from '@wordpress/e2e-test-utils'; const MOCK_EMBED_WORDPRESS_SUCCESS_RESPONSE = { @@ -60,6 +62,10 @@ const MOCK_BAD_WORDPRESS_RESPONSE = { }; const MOCK_RESPONSES = [ + { + match: createEmbeddingMatcher( 'https://wordpress.org/gutenberg/handbook' ), + onRequestMatch: createJSONResponse( MOCK_BAD_WORDPRESS_RESPONSE ), + }, { match: createEmbeddingMatcher( 'https://wordpress.org/gutenberg/handbook/' ), onRequestMatch: createJSONResponse( MOCK_BAD_WORDPRESS_RESPONSE ), @@ -80,6 +86,10 @@ const MOCK_RESPONSES = [ match: createEmbeddingMatcher( 'https://twitter.com/notnownikki' ), onRequestMatch: createJSONResponse( MOCK_EMBED_RICH_SUCCESS_RESPONSE ), }, + { + match: createEmbeddingMatcher( 'https://twitter.com/notnownikki/' ), + onRequestMatch: createJSONResponse( MOCK_CANT_EMBED_RESPONSE ), + }, { match: createEmbeddingMatcher( 'https://twitter.com/thatbunty' ), onRequestMatch: createJSONResponse( MOCK_BAD_EMBED_PROVIDER_RESPONSE ), @@ -173,6 +183,17 @@ describe( 'Embedding content', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + it( 'should retry embeds that could not be embedded with trailing slashes, without the trailing slashes', async () => { + await clickBlockAppender(); + await page.keyboard.type( '/embed' ); + await page.keyboard.press( 'Enter' ); + // This URL can't be embedded, but without the trailing slash, it can. + await page.keyboard.type( 'https://twitter.com/notnownikki/' ); + await page.keyboard.press( 'Enter' ); + // The twitter block should appear correctly. + await page.waitForSelector( 'figure.wp-block-embed-twitter' ); + } ); + it( 'should allow the user to try embedding a failed URL again', async () => { // URL that can't be embedded. await clickBlockAppender(); @@ -192,4 +213,27 @@ describe( 'Embedding content', () => { await clickButton( 'Try again' ); await page.waitForSelector( 'figure.wp-block-embed-twitter' ); } ); + + it( 'should switch to the WordPress block correctly', async () => { + // This test is to make sure that WordPress embeds are detected correctly, + // because the HTML can vary, and the block is detected by looking for + // classes in the HTML, so we need to flag up if the HTML changes. + + // Publish a post to embed. + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'Hello there!' ); + await publishPost(); + const postUrl = await page.$eval( '#inspector-text-control-0', ( el ) => el.value ); + + // Start a new post, embed the previous post. + await createNewPost(); + await clickBlockAppender(); + await page.keyboard.type( '/embed' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( postUrl ); + await page.keyboard.press( 'Enter' ); + + // Check the block has become a WordPress block. + await page.waitForSelector( '.wp-block-embed-wordpress' ); + } ); } ); diff --git a/packages/e2e-tests/specs/multi-block-selection.test.js b/packages/e2e-tests/specs/multi-block-selection.test.js index 76ea211367660d..6bf7a62bf7789e 100644 --- a/packages/e2e-tests/specs/multi-block-selection.test.js +++ b/packages/e2e-tests/specs/multi-block-selection.test.js @@ -181,6 +181,19 @@ describe( 'Multi-block selection', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + it( 'should always expand single line selection', async () => { + await clickBlockAppender(); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '12' ); + await page.keyboard.press( 'ArrowLeft' ); + await pressKeyWithModifier( 'shift', 'ArrowRight' ); + await pressKeyWithModifier( 'shift', 'ArrowUp' ); + // This delete all blocks. + await page.keyboard.press( 'Backspace' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + it( 'should allow selecting outer edge if there is no sibling block', async () => { await clickBlockAppender(); await page.keyboard.type( '1' ); diff --git a/packages/e2e-tests/specs/rich-text.test.js b/packages/e2e-tests/specs/rich-text.test.js index 5153032953899e..dcb1434abd89be 100644 --- a/packages/e2e-tests/specs/rich-text.test.js +++ b/packages/e2e-tests/specs/rich-text.test.js @@ -58,6 +58,34 @@ describe( 'RichText', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + it( 'should apply multiple formats when selection is collapsed', async () => { + await clickBlockAppender(); + await pressKeyWithModifier( 'primary', 'b' ); + await pressKeyWithModifier( 'primary', 'i' ); + await page.keyboard.type( '1' ); + await pressKeyWithModifier( 'primary', 'i' ); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( '.' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should not highlight more than one format', async () => { + await clickBlockAppender(); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( '1' ); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( ' 2' ); + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); + await pressKeyWithModifier( 'primary', 'b' ); + + const count = await page.evaluate( () => document.querySelectorAll( + '*[data-rich-text-format-boundary]' + ).length ); + + expect( count ).toBe( 1 ); + } ); + it( 'should return focus when pressing formatting button', async () => { await clickBlockAppender(); await page.keyboard.type( 'Some ' ); diff --git a/packages/e2e-tests/specs/writing-flow.test.js b/packages/e2e-tests/specs/writing-flow.test.js index 62ece158016405..c186507a7cdef3 100644 --- a/packages/e2e-tests/specs/writing-flow.test.js +++ b/packages/e2e-tests/specs/writing-flow.test.js @@ -298,4 +298,16 @@ describe( 'adding blocks', () => { // Check that none of the paragraph blocks have <br> in them. expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + + it( 'should navigate empty paragraph', async () => { + await clickBlockAppender(); + await page.keyboard.press( 'Enter' ); + await page.waitForFunction( () => document.activeElement.isContentEditable ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.type( '1' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.type( '2' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); } ); diff --git a/packages/edit-post/CHANGELOG.md b/packages/edit-post/CHANGELOG.md index db8c68da542295..4e58838aade5e6 100644 --- a/packages/edit-post/CHANGELOG.md +++ b/packages/edit-post/CHANGELOG.md @@ -5,7 +5,9 @@ * Expose the `className` property to style the `PluginSidebar` component. ### Bug Fixes - - Fix 'save' keyboard shortcut not functioning in the Code Editor. + +- Fix 'save' keyboard shortcut not functioning in the Code Editor. +- Prevent `ClipboardButton` from incorrectly copying a serialized block string instead of the intended text in Safari. ## 3.1.7 (2019-01-03) diff --git a/packages/edit-post/src/components/block-settings-menu/plugin-block-settings-menu-item.js b/packages/edit-post/src/components/block-settings-menu/plugin-block-settings-menu-item.js index 810d558346cc79..7a7451a983ce3b 100644 --- a/packages/edit-post/src/components/block-settings-menu/plugin-block-settings-menu-item.js +++ b/packages/edit-post/src/components/block-settings-menu/plugin-block-settings-menu-item.js @@ -6,7 +6,7 @@ import { difference } from 'lodash'; /** * WordPress dependencies */ -import { IconButton } from '@wordpress/components'; +import { MenuItem } from '@wordpress/components'; import { compose } from '@wordpress/compose'; /** @@ -89,7 +89,7 @@ const PluginBlockSettingsMenuItem = ( { allowedBlocks, icon, label, onClick, sma if ( ! shouldRenderItem( selectedBlocks, allowedBlocks ) ) { return null; } - return ( <IconButton + return ( <MenuItem className="editor-block-settings-menu__control" onClick={ compose( onClick, onClose ) } icon={ icon || 'admin-plugins' } @@ -97,7 +97,7 @@ const PluginBlockSettingsMenuItem = ( { allowedBlocks, icon, label, onClick, sma role={ role } > { ! small && label } - </IconButton> ); + </MenuItem> ); } } </PluginBlockSettingsMenuGroup> ); diff --git a/packages/edit-post/src/components/header/feature-toggle/index.js b/packages/edit-post/src/components/header/feature-toggle/index.js index 4e0df7f3749b08..b855ca046c3866 100644 --- a/packages/edit-post/src/components/header/feature-toggle/index.js +++ b/packages/edit-post/src/components/header/feature-toggle/index.js @@ -26,7 +26,6 @@ function FeatureToggle( { onToggle, isActive, label, info, messageActivated, mes isSelected={ isActive } onClick={ flow( onToggle, speakMessage ) } role="menuitemcheckbox" - label={ label } info={ info } > { label } diff --git a/packages/edit-post/src/components/header/plugin-more-menu-item/test/__snapshots__/index.js.snap b/packages/edit-post/src/components/header/plugin-more-menu-item/test/__snapshots__/index.js.snap index 6e395b2e7ebae9..7fa0869a60f223 100644 --- a/packages/edit-post/src/components/header/plugin-more-menu-item/test/__snapshots__/index.js.snap +++ b/packages/edit-post/src/components/header/plugin-more-menu-item/test/__snapshots__/index.js.snap @@ -18,6 +18,7 @@ exports[`PluginMoreMenuItem renders menu item as button properly 1`] = ` > <button className="components-button components-icon-button components-menu-item__button has-icon has-text" + instanceId={0} onClick={[Function]} role="menuitem" type="button" diff --git a/packages/edit-post/src/components/sidebar/style.scss b/packages/edit-post/src/components/sidebar/style.scss index 793088a0390911..62936b0f0a7ff6 100644 --- a/packages/edit-post/src/components/sidebar/style.scss +++ b/packages/edit-post/src/components/sidebar/style.scss @@ -40,17 +40,10 @@ z-index: z-index(".edit-post-sidebar .components-panel"); @include break-small() { - overflow: inherit; + overflow: hidden; height: auto; max-height: none; } - - @include break-medium() { - - body.is-fullscreen-mode & { - max-height: calc(100vh - #{ $panel-header-height }); - } - } } > .components-panel .components-panel__header { diff --git a/packages/edit-post/src/components/visual-editor/block-inspector-button.js b/packages/edit-post/src/components/visual-editor/block-inspector-button.js index 283c3f99b86a70..cf73fe27de92f7 100644 --- a/packages/edit-post/src/components/visual-editor/block-inspector-button.js +++ b/packages/edit-post/src/components/visual-editor/block-inspector-button.js @@ -39,7 +39,6 @@ export function BlockInspectorButton( { className="editor-block-settings-menu__control block-editor-block-settings-menu__control" onClick={ flow( areAdvancedSettingsOpened ? closeSidebar : openEditorSidebar, speakMessage, onClick ) } icon="admin-generic" - label={ small ? label : undefined } shortcut={ shortcuts.toggleSidebar } > { ! small && label } diff --git a/packages/edit-post/src/components/visual-editor/index.js b/packages/edit-post/src/components/visual-editor/index.js index eadf8a5fc461df..ca6a1c45212b6b 100644 --- a/packages/edit-post/src/components/visual-editor/index.js +++ b/packages/edit-post/src/components/visual-editor/index.js @@ -26,12 +26,13 @@ function VisualEditor() { return ( <BlockSelectionClearer className="edit-post-visual-editor editor-styles-wrapper"> <VisualEditorGlobalKeyboardShortcuts /> - <CopyHandler /> <MultiSelectScrollIntoView /> <WritingFlow> <ObserveTyping> - <PostTitle /> - <BlockList /> + <CopyHandler> + <PostTitle /> + <BlockList /> + </CopyHandler> </ObserveTyping> </WritingFlow> <_BlockSettingsMenuFirstItem> diff --git a/packages/rich-text/src/apply-format.js b/packages/rich-text/src/apply-format.js index c9a59b96e6a656..14402735b1e60c 100644 --- a/packages/rich-text/src/apply-format.js +++ b/packages/rich-text/src/apply-format.js @@ -28,7 +28,8 @@ export function applyFormat( startIndex = value.start, endIndex = value.end ) { - const newFormats = value.formats.slice( 0 ); + const { formats, activeFormats = [] } = value; + const newFormats = formats.slice(); // The selection is collapsed. if ( startIndex === endIndex ) { @@ -51,11 +52,9 @@ export function applyFormat( // Otherwise, insert a placeholder with the format so new input appears // with the format applied. } else { - const previousFormat = newFormats[ startIndex - 1 ] || []; - return { ...value, - formatPlaceholder: [ ...previousFormat, format ], + activeFormats: [ ...activeFormats, format ], }; } } else { diff --git a/packages/rich-text/src/get-active-formats.js b/packages/rich-text/src/get-active-formats.js index fd0f869877bfb1..47ec6b56fea975 100644 --- a/packages/rich-text/src/get-active-formats.js +++ b/packages/rich-text/src/get-active-formats.js @@ -5,19 +5,29 @@ * * @return {?Object} Active format objects. */ -export function getActiveFormats( { formats, start, selectedFormat } ) { +export function getActiveFormats( { formats, start, end, activeFormats } ) { if ( start === undefined ) { return []; } - const formatsBefore = formats[ start - 1 ] || []; - const formatsAfter = formats[ start ] || []; + if ( start === end ) { + // For a collapsed caret, it is possible to override the active formats. + if ( activeFormats ) { + return activeFormats; + } - let source = formatsAfter; + const formatsBefore = formats[ start - 1 ] || []; + const formatsAfter = formats[ start ] || []; - if ( formatsBefore.length > formatsAfter.length ) { - source = formatsBefore; + // By default, select the lowest amount of formats possible (which means + // the caret is positioned outside the format boundary). The user can + // then use arrow keys to define `activeFormats`. + if ( formatsBefore.length < formatsAfter.length ) { + return formatsBefore; + } + + return formatsAfter; } - return source.slice( 0, selectedFormat ); + return formats[ start ] || []; } diff --git a/packages/rich-text/src/index.js b/packages/rich-text/src/index.js index 9d9f4620212876..63da636257619d 100644 --- a/packages/rich-text/src/index.js +++ b/packages/rich-text/src/index.js @@ -33,3 +33,5 @@ export { unregisterFormatType } from './unregister-format-type'; export { indentListItems } from './indent-list-items'; export { outdentListItems } from './outdent-list-items'; export { changeListType } from './change-list-type'; +export { updateFormats as __unstableUpdateFormats } from './update-formats'; +export { getActiveFormats as __unstableGetActiveFormats } from './get-active-formats'; diff --git a/packages/rich-text/src/normalise-formats.js b/packages/rich-text/src/normalise-formats.js index 72c04f818f9a4f..460388e0a74cbf 100644 --- a/packages/rich-text/src/normalise-formats.js +++ b/packages/rich-text/src/normalise-formats.js @@ -1,9 +1,3 @@ -/** - * External dependencies - */ - -import { find } from 'lodash'; - /** * Internal dependencies */ @@ -11,29 +5,36 @@ import { find } from 'lodash'; import { isFormatEqual } from './is-format-equal'; /** - * Normalises formats: ensures subsequent equal formats have the same reference. + * Normalises formats: ensures subsequent adjacent equal formats have the same + * reference. * * @param {Object} value Value to normalise formats of. * * @return {Object} New value with normalised formats. */ export function normaliseFormats( value ) { - const refs = []; - const newFormats = value.formats.map( ( formatsAtIndex ) => - formatsAtIndex.map( ( format ) => { - const equalRef = find( refs, ( ref ) => - isFormatEqual( ref, format ) - ); + const newFormats = value.formats.slice(); + + newFormats.forEach( ( formatsAtIndex, index ) => { + const formatsAtPreviousIndex = newFormats[ index - 1 ]; + + if ( formatsAtPreviousIndex ) { + const newFormatsAtIndex = formatsAtIndex.slice(); - if ( equalRef ) { - return equalRef; - } + newFormatsAtIndex.forEach( ( format, formatIndex ) => { + const previousFormat = formatsAtPreviousIndex[ formatIndex ]; - refs.push( format ); + if ( isFormatEqual( format, previousFormat ) ) { + newFormatsAtIndex[ formatIndex ] = previousFormat; + } + } ); - return format; - } ) - ); + newFormats[ index ] = newFormatsAtIndex; + } + } ); - return { ...value, formats: newFormats }; + return { + ...value, + formats: newFormats, + }; } diff --git a/packages/rich-text/src/remove-format.js b/packages/rich-text/src/remove-format.js index 405cb6265e1e06..4a4c9c820f9008 100644 --- a/packages/rich-text/src/remove-format.js +++ b/packages/rich-text/src/remove-format.js @@ -28,7 +28,8 @@ export function removeFormat( startIndex = value.start, endIndex = value.end ) { - const newFormats = value.formats.slice( 0 ); + const { formats, activeFormats } = value; + const newFormats = formats.slice(); // If the selection is collapsed, expand start and end to the edges of the // format. @@ -50,10 +51,7 @@ export function removeFormat( } else { return { ...value, - formatPlaceholder: reject( - newFormats[ startIndex - 1 ] || [], - { type: formatType } - ), + activeFormats: reject( activeFormats, { type: formatType } ), }; } } else { diff --git a/packages/rich-text/src/test/apply-format.js b/packages/rich-text/src/test/apply-format.js index b8cff3bc476202..b75dc7ee5de008 100644 --- a/packages/rich-text/src/test/apply-format.js +++ b/packages/rich-text/src/test/apply-format.js @@ -61,7 +61,7 @@ describe( 'applyFormat', () => { }; const expected = { ...record, - formatPlaceholder: [ a2 ], + activeFormats: [ a2 ], }; const result = applyFormat( deepFreeze( record ), a2 ); diff --git a/packages/rich-text/src/test/get-active-format.js b/packages/rich-text/src/test/get-active-format.js index 4fa0ddaa3e3ec6..eba352783c39ab 100644 --- a/packages/rich-text/src/test/get-active-format.js +++ b/packages/rich-text/src/test/get-active-format.js @@ -6,39 +6,82 @@ import { getActiveFormat } from '../get-active-format'; describe( 'getActiveFormat', () => { const em = { type: 'em' }; + const strong = { type: 'strong' }; - it( 'should get format by selection', () => { + it( 'should return undefined if there is no selection', () => { const record = { - formats: [ [ em ], , , ], + formats: [ [ em ], [ em ], [ em ] ], + text: 'one', + }; + + expect( getActiveFormat( record, 'em' ) ).toBe( undefined ); + } ); + + it( 'should return format at first character for uncollapsed selection', () => { + const record = { + formats: [ [ em ], [ strong ], , ], text: 'one', start: 0, - end: 0, + end: 2, }; - expect( getActiveFormat( record, 'em' ) ).toEqual( em ); + expect( getActiveFormat( record, 'em' ) ).toBe( em ); } ); - it( 'should not get any format if outside boundary position', () => { + it( 'should return undefined if at the boundary before', () => { + const record = { + formats: [ [ em ], , [ em ] ], + text: 'one', + start: 3, + end: 3, + }; + + expect( getActiveFormat( record, 'em' ) ).toBe( undefined ); + } ); + + it( 'should return undefined if at the boundary after', () => { const record = { formats: [ [ em ], , [ em ] ], text: 'one', start: 1, end: 1, - selectedFormat: 0, }; expect( getActiveFormat( record, 'em' ) ).toBe( undefined ); } ); - it( 'should get format if inside boundary position', () => { + it( 'should return format if inside format', () => { + const record = { + formats: [ [ em ], [ em ], [ em ] ], + text: 'one', + start: 1, + end: 1, + }; + + expect( getActiveFormat( record, 'em' ) ).toBe( em ); + } ); + + it( 'should return activeFormats', () => { const record = { formats: [ [ em ], , [ em ] ], text: 'one', start: 1, end: 1, - selectedFormat: 1, + activeFormats: [ em ], }; expect( getActiveFormat( record, 'em' ) ).toBe( em ); } ); + + it( 'should not return activeFormats for uncollapsed selection', () => { + const record = { + formats: [ [ em ], , [ em ] ], + text: 'one', + start: 1, + end: 2, + activeFormats: [ em ], + }; + + expect( getActiveFormat( record, 'em' ) ).toBe( undefined ); + } ); } ); diff --git a/packages/rich-text/src/test/normalise-formats.js b/packages/rich-text/src/test/normalise-formats.js index 25525625f18b5d..c71b8a35ed634d 100644 --- a/packages/rich-text/src/test/normalise-formats.js +++ b/packages/rich-text/src/test/normalise-formats.js @@ -26,7 +26,7 @@ describe( 'normaliseFormats', () => { expect( getSparseArrayLength( result.formats ) ).toBe( 4 ); expect( result.formats[ 1 ][ 0 ] ).toBe( result.formats[ 2 ][ 0 ] ); expect( result.formats[ 1 ][ 0 ] ).toBe( result.formats[ 3 ][ 0 ] ); - expect( result.formats[ 1 ][ 0 ] ).toBe( result.formats[ 5 ][ 0 ] ); + expect( result.formats[ 1 ][ 0 ] ).not.toBe( result.formats[ 5 ][ 0 ] ); expect( result.formats[ 2 ][ 1 ] ).toBe( result.formats[ 3 ][ 1 ] ); } ); } ); diff --git a/packages/rich-text/src/test/update-formats.js b/packages/rich-text/src/test/update-formats.js new file mode 100644 index 00000000000000..e5c3c97a85bb77 --- /dev/null +++ b/packages/rich-text/src/test/update-formats.js @@ -0,0 +1,55 @@ +/** + * Internal dependencies + */ + +import { updateFormats } from '../update-formats'; +import { getSparseArrayLength } from './helpers'; + +describe( 'updateFormats', () => { + const em = { type: 'em' }; + + it( 'should update formats with empty array', () => { + const value = { + formats: [ [ em ] ], + text: '1', + }; + const expected = { + ...value, + activeFormats: [], + formats: [ , ], + }; + const result = updateFormats( { + value, + start: 0, + end: 1, + formats: [], + } ); + + expect( result ).toEqual( expected ); + expect( result ).toBe( value ); + expect( getSparseArrayLength( result.formats ) ).toBe( 0 ); + } ); + + it( 'should update formats and update references', () => { + const value = { + formats: [ [ em ], , ], + text: '123', + }; + const expected = { + ...value, + activeFormats: [ em ], + formats: [ [ em ], [ em ] ], + }; + const result = updateFormats( { + value, + start: 1, + end: 2, + formats: [ { ...em } ], + } ); + + expect( result ).toEqual( expected ); + expect( result ).toBe( value ); + expect( result.formats[ 1 ][ 0 ] ).toBe( em ); + expect( getSparseArrayLength( result.formats ) ).toBe( 2 ); + } ); +} ); diff --git a/packages/rich-text/src/to-tree.js b/packages/rich-text/src/to-tree.js index ef15fe7524b6ea..c76eafc803b92f 100644 --- a/packages/rich-text/src/to-tree.js +++ b/packages/rich-text/src/to-tree.js @@ -69,17 +69,6 @@ function fromFormat( { type, attributes, unregisteredAttributes, object, boundar }; } -function getDeepestActiveFormat( value ) { - const activeFormats = getActiveFormats( value ); - const { selectedFormat } = value; - - if ( selectedFormat === undefined ) { - return activeFormats[ activeFormats.length - 1 ]; - } - - return activeFormats[ selectedFormat - 1 ]; -} - const padding = { type: 'br', attributes: { @@ -107,7 +96,8 @@ export function toTree( { const formatsLength = formats.length + 1; const tree = createEmpty(); const multilineFormat = { type: multilineTag }; - const deepestActiveFormat = getDeepestActiveFormat( value ); + const activeFormats = getActiveFormats( value ); + const deepestActiveFormat = activeFormats[ activeFormats.length - 1 ]; let lastSeparatorFormats; let lastCharacterFormats; diff --git a/packages/rich-text/src/update-formats.js b/packages/rich-text/src/update-formats.js new file mode 100644 index 00000000000000..bc99c9ba0e5dbe --- /dev/null +++ b/packages/rich-text/src/update-formats.js @@ -0,0 +1,48 @@ +/** + * Internal dependencies + */ + +import { isFormatEqual } from './is-format-equal'; + +/** + * Efficiently updates all the formats from `start` (including) until `end` + * (excluding) with the active formats. Mutates `value`. + * + * @param {Object} $1 Named paramentes. + * @param {Object} $1.value Value te update. + * @param {number} $1.start Index to update from. + * @param {number} $1.end Index to update until. + * @param {Array} $1.formats Replacement formats. + * + * @return {Object} Mutated value. + */ +export function updateFormats( { value, start, end, formats } ) { + const formatsBefore = value.formats[ start - 1 ] || []; + const formatsAfter = value.formats[ end ] || []; + + // First, fix the references. If any format right before or after are + // equal, the replacement format should use the same reference. + value.activeFormats = formats.map( ( format, index ) => { + if ( formatsBefore[ index ] ) { + if ( isFormatEqual( format, formatsBefore[ index ] ) ) { + return formatsBefore[ index ]; + } + } else if ( formatsAfter[ index ] ) { + if ( isFormatEqual( format, formatsAfter[ index ] ) ) { + return formatsAfter[ index ]; + } + } + + return format; + } ); + + while ( --end >= start ) { + if ( value.activeFormats.length > 0 ) { + value.formats[ end ] = value.activeFormats; + } else { + delete value.formats[ end ]; + } + } + + return value; +} diff --git a/test/integration/blocks-raw-handling.spec.js b/test/integration/blocks-raw-handling.spec.js index 72426e00094fb8..83315ba0d38a68 100644 --- a/test/integration/blocks-raw-handling.spec.js +++ b/test/integration/blocks-raw-handling.spec.js @@ -221,6 +221,14 @@ describe( 'Blocks raw handling', () => { expect( console ).toHaveLogged(); } ); + it( 'should paste gutenberg content from plain text', () => { + const block = '<!-- wp:latest-posts /-->'; + expect( serialize( pasteHandler( { + plainText: block, + mode: 'AUTO', + } ) ) ).toBe( block ); + } ); + describe( 'pasteHandler', () => { [ 'plain',