diff --git a/docs/designers-developers/developers/data/data-core-block-editor.md b/docs/designers-developers/developers/data/data-core-block-editor.md index 03929e3671685..dcdff0b67aa14 100644 --- a/docs/designers-developers/developers/data/data-core-block-editor.md +++ b/docs/designers-developers/developers/data/data-core-block-editor.md @@ -763,6 +763,20 @@ via its `onChange` callback, in addition to `onInput`. Whether the most recent block change was persistent. +### __unstableIsLastBlockChangeIgnored + +Returns true if the most recent block change is be considered ignored, or +false otherwise. An ignored change is one not to be committed by +BlockEditorProvider, neither via `onChange` nor `onInput`. + +*Parameters* + + * state: Block editor state. + +*Returns* + +Whether the most recent block change was ignored. + ## Actions ### resetBlocks @@ -980,6 +994,17 @@ specified client ID is to be removed. * selectPrevious: True if the previous block should be selected when a block is removed. +### replaceInnerBlocks + +Returns an action object used in signalling that the inner blocks with the +specified client ID should be replaced. + +*Parameters* + + * rootClientId: Client ID of the block whose InnerBlocks will re replaced. + * blocks: Block objects to insert as new InnerBlocks + * updateSelection: If true block selection will be updated. If false, block selection will not change. Defaults to true. + ### toggleBlockMode Returns an action object used to toggle the block editing mode between diff --git a/docs/designers-developers/faq.md b/docs/designers-developers/faq.md index 4917ac8f82e3c..9bd9c8fae2530 100644 --- a/docs/designers-developers/faq.md +++ b/docs/designers-developers/faq.md @@ -102,13 +102,13 @@ This is the canonical list of keyboard shortcuts: Navigate to a the next part of the editor (alternative). - Shift+Alt+N - N + Ctrl+Alt+N + N Navigate to the previous part of the editor (alternative). - Shift+Alt+P - P + Ctrl+Alt+P + P Navigate to the nearest toolbar. diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index 4306b855955f5..0d4c44b269f24 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -1,9 +1,18 @@ ## 2.0.0 (Unreleased) +### New Features + +- The `URLPopover` component now passes through all unhandled props to the underlying Popover component. + ### Breaking Changes - `CopyHandler` will now only catch cut/copy events coming from its `props.children`, instead of from anywhere in the `document`. +### Internal + +- Improved handling of blocks state references for unchanging states. +- Updated handling of blocks state to effectively ignored programmatically-received blocks data (e.g. reusable blocks received from editor). + ## 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 5f48fe5a165db..a1a358cbc84e3 100644 --- a/packages/block-editor/src/components/block-list/style.scss +++ b/packages/block-editor/src/components/block-list/style.scss @@ -737,10 +737,11 @@ left: 0; right: 0; justify-content: center; + height: $block-padding + 8px; // Show a clickable plus. .block-editor-inserter__toggle { - margin-top: -4px; + margin-top: -8px; border-radius: 50%; color: $blue-medium-focus; background: $white; diff --git a/packages/block-editor/src/components/default-block-appender/style.scss b/packages/block-editor/src/components/default-block-appender/style.scss index d9728e201c1ec..6a268661c34e4 100644 --- a/packages/block-editor/src/components/default-block-appender/style.scss +++ b/packages/block-editor/src/components/default-block-appender/style.scss @@ -74,6 +74,12 @@ .block-editor-inserter__toggle { margin-right: 0; + + // Hide the box shadow that appears on hover. + // All the :not() rules are needed to override default iconButton styles. + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):hover { + box-shadow: none; + } } } diff --git a/packages/block-editor/src/components/inner-blocks/index.js b/packages/block-editor/src/components/inner-blocks/index.js index 1dd6292ab324f..b55802a0dfe90 100644 --- a/packages/block-editor/src/components/inner-blocks/index.js +++ b/packages/block-editor/src/components/inner-blocks/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { pick, isEqual, map } from 'lodash'; +import { pick, isEqual } from 'lodash'; import classnames from 'classnames'; /** @@ -144,20 +144,14 @@ InnerBlocks = compose( [ } ), withDispatch( ( dispatch, ownProps ) => { const { - replaceBlocks, - insertBlocks, + replaceInnerBlocks, updateBlockListSettings, } = dispatch( 'core/block-editor' ); const { block, clientId, templateInsertUpdatesSelection = true } = ownProps; return { replaceInnerBlocks( blocks ) { - const clientIds = map( block.innerBlocks, 'clientId' ); - if ( clientIds.length ) { - replaceBlocks( clientIds, blocks ); - } else { - insertBlocks( blocks, undefined, clientId, templateInsertUpdatesSelection ); - } + replaceInnerBlocks( clientId, blocks, block.innerBlocks.length === 0 && templateInsertUpdatesSelection ); }, updateNestedSettings( settings ) { dispatch( updateBlockListSettings( clientId, settings ) ); diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js index fd788477a0d6d..7c33002b65e87 100644 --- a/packages/block-editor/src/components/provider/index.js +++ b/packages/block-editor/src/components/provider/index.js @@ -64,6 +64,7 @@ class BlockEditorProvider extends Component { const { getBlocks, isLastBlockChangePersistent, + __unstableIsLastBlockChangeIgnored, } = registry.select( 'core/block-editor' ); let blocks = getBlocks(); @@ -76,7 +77,12 @@ class BlockEditorProvider extends Component { } = this.props; const newBlocks = getBlocks(); const newIsPersistent = isLastBlockChangePersistent(); - if ( newBlocks !== blocks && this.isSyncingIncomingValue ) { + if ( + newBlocks !== blocks && ( + this.isSyncingIncomingValue || + __unstableIsLastBlockChangeIgnored() + ) + ) { this.isSyncingIncomingValue = false; blocks = newBlocks; isPersistent = newIsPersistent; @@ -88,10 +94,15 @@ class BlockEditorProvider extends Component { // This happens when a previous input is explicitely marked as persistent. ( newIsPersistent && ! isPersistent ) ) { + // When knowing the blocks value is changing, assign instance + // value to skip reset in subsequent `componentDidUpdate`. + if ( newBlocks !== blocks ) { + this.isSyncingOutcomingValue = true; + } + blocks = newBlocks; isPersistent = newIsPersistent; - this.isSyncingOutcomingValue = true; if ( isPersistent ) { onChange( blocks ); } else { diff --git a/packages/block-editor/src/components/rich-text/style.scss b/packages/block-editor/src/components/rich-text/style.scss index 4a52ddb56fcd3..6adbbc8844d7e 100644 --- a/packages/block-editor/src/components/rich-text/style.scss +++ b/packages/block-editor/src/components/rich-text/style.scss @@ -16,7 +16,7 @@ // breaking spaces in between words. If also prevent Firefox from inserting // a trailing `br` node to visualise any trailing space, causing the element // to be saved. - white-space: pre-wrap; + white-space: pre-wrap !important; > p:first-child { margin-top: 0; diff --git a/packages/block-editor/src/components/url-popover/README.md b/packages/block-editor/src/components/url-popover/README.md index 4705ecadfe437..bd5c634255545 100644 --- a/packages/block-editor/src/components/url-popover/README.md +++ b/packages/block-editor/src/components/url-popover/README.md @@ -85,7 +85,7 @@ class MyURLPopover extends Component { ## Props -The component accepts the following props. +The component accepts the following props. Any other props are passed through to the underlying `Popover` component ([refer to props documentation](/packages/components/src/popover/README.md)). ### position @@ -104,14 +104,6 @@ an element. - Required: No - Default: "firstElement" -### onClose - -Callback that triggers when the user indicates the popover should close (e.g. they've used the escape key or clicked -outside of the popover.) - -- Type: `Function` -- Required: No - ### renderSettings Callback used to return the React Elements that will be rendered inside the settings drawer. When this function diff --git a/packages/block-editor/src/components/url-popover/index.js b/packages/block-editor/src/components/url-popover/index.js index d173d110c3977..103126d420abc 100644 --- a/packages/block-editor/src/components/url-popover/index.js +++ b/packages/block-editor/src/components/url-popover/index.js @@ -29,10 +29,9 @@ class URLPopover extends Component { const { children, renderSettings, - onClose, - onClickOutside, position = 'bottom center', focusOnMount = 'firstElement', + ...popoverProps } = this.props; const { @@ -46,8 +45,7 @@ class URLPopover extends Component { className="editor-url-popover block-editor-url-popover" focusOnMount={ focusOnMount } position={ position } - onClose={ onClose } - onClickOutside={ onClickOutside } + { ...popoverProps } >
{ children } diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index a2a1bc6e6e628..16910972b1f12 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { castArray } from 'lodash'; +import { castArray, first } from 'lodash'; /** * WordPress dependencies @@ -13,6 +13,25 @@ import { getDefaultBlockName, createBlock } from '@wordpress/blocks'; */ import { select } from './controls'; +/** + * Generator which will yield a default block insert action if there + * are no other blocks at the root of the editor. This generator should be used + * in actions which may result in no blocks remaining in the editor (removal, + * replacement, etc). + */ +function* ensureDefaultBlock() { + const count = yield select( + 'core/block-editor', + 'getBlockCount', + ); + + // To avoid a focus loss when removing the last block, assure there is + // always a default block if the last of the blocks have been removed. + if ( count === 0 ) { + yield insertDefaultBlock(); + } +} + /** * Returns an action object used in signalling that blocks state should be * reset to the specified array of blocks, taking precedence over any other @@ -202,15 +221,36 @@ export function toggleSelection( isSelectionEnabled = true ) { * @param {(string|string[])} clientIds Block client ID(s) to replace. * @param {(Object|Object[])} blocks Replacement block(s). * - * @return {Object} Action object. + * @yields {Object} Action object. */ -export function replaceBlocks( clientIds, blocks ) { - return { +export function* replaceBlocks( clientIds, blocks ) { + clientIds = castArray( clientIds ); + blocks = castArray( blocks ); + const rootClientId = yield select( + 'core/block-editor', + 'getBlockRootClientId', + first( clientIds ) + ); + // Replace is valid if the new blocks can be inserted in the root block. + for ( let index = 0; index < blocks.length; index++ ) { + const block = blocks[ index ]; + const canInsertBlock = yield select( + 'core/block-editor', + 'canInsertBlockType', + block.name, + rootClientId + ); + if ( ! canInsertBlock ) { + return; + } + } + yield { type: 'REPLACE_BLOCKS', - clientIds: castArray( clientIds ), - blocks: castArray( blocks ), + clientIds, + blocks, time: Date.now(), }; + yield* ensureDefaultBlock(); } /** @@ -256,16 +296,51 @@ export const moveBlocksUp = createOnMove( 'MOVE_BLOCKS_UP' ); * @param {?string} toRootClientId Root client ID destination. * @param {number} index The index to move the block into. * - * @return {Object} Action object. + * @yields {Object} Action object. */ -export function moveBlockToPosition( clientId, fromRootClientId, toRootClientId, index ) { - return { +export function* moveBlockToPosition( clientId, fromRootClientId, toRootClientId, index ) { + const templateLock = yield select( + 'core/block-editor', + 'getTemplateLock', + fromRootClientId + ); + + // If locking is equal to all on the original clientId (fromRootClientId), + // it is not possible to move the block to any other position. + if ( templateLock === 'all' ) { + return; + } + + const action = { type: 'MOVE_BLOCK_TO_POSITION', fromRootClientId, toRootClientId, clientId, index, }; + // If moving inside the same root block the move is always possible. + if ( fromRootClientId === toRootClientId ) { + yield action; + return; + } + + const blockName = yield select( + 'core/block-editor', + 'getBlockName', + clientId + ); + + const canInsertBlock = yield select( + 'core/block-editor', + 'canInsertBlockType', + blockName, + toRootClientId + ); + + // If moving to other parent block, the move is possible if we can insert a block of the same type inside the new parent block. + if ( canInsertBlock ) { + yield action; + } } /** @@ -279,8 +354,18 @@ export function moveBlockToPosition( clientId, fromRootClientId, toRootClientId, * * @return {Object} Action object. */ -export function insertBlock( block, index, rootClientId, updateSelection = true ) { - return insertBlocks( [ block ], index, rootClientId, updateSelection ); +export function insertBlock( + block, + index, + rootClientId, + updateSelection = true, +) { + return insertBlocks( + [ block ], + index, + rootClientId, + updateSelection + ); } /** @@ -292,17 +377,37 @@ export function insertBlock( block, index, rootClientId, updateSelection = true * @param {?string} rootClientId Optional root client ID of block list on which to insert. * @param {?boolean} updateSelection If true block selection will be updated. If false, block selection will not change. Defaults to true. * - * @return {Object} Action object. - */ -export function insertBlocks( blocks, index, rootClientId, updateSelection = true ) { - return { - type: 'INSERT_BLOCKS', - blocks: castArray( blocks ), - index, - rootClientId, - time: Date.now(), - updateSelection, - }; + * @return {Object} Action object. + */ +export function* insertBlocks( + blocks, + index, + rootClientId, + updateSelection = true +) { + blocks = castArray( blocks ); + const allowedBlocks = []; + for ( const block of blocks ) { + const isValid = yield select( + 'core/block-editor', + 'canInsertBlockType', + block.name, + rootClientId + ); + if ( isValid ) { + allowedBlocks.push( block ); + } + } + if ( allowedBlocks.length ) { + return { + type: 'INSERT_BLOCKS', + blocks: allowedBlocks, + index, + rootClientId, + time: Date.now(), + updateSelection, + }; + } } /** @@ -394,16 +499,9 @@ export function* removeBlocks( clientIds, selectPrevious = true ) { clientIds, }; - const count = yield select( - 'core/block-editor', - 'getBlockCount', - ); - // To avoid a focus loss when removing the last block, assure there is // always a default block if the last of the blocks have been removed. - if ( count === 0 ) { - yield insertDefaultBlock(); - } + yield* ensureDefaultBlock(); } /** @@ -420,6 +518,26 @@ export function removeBlock( clientId, selectPrevious ) { return removeBlocks( [ clientId ], selectPrevious ); } +/** + * Returns an action object used in signalling that the inner blocks with the + * specified client ID should be replaced. + * + * @param {string} rootClientId Client ID of the block whose InnerBlocks will re replaced. + * @param {Object[]} blocks Block objects to insert as new InnerBlocks + * @param {?boolean} updateSelection If true block selection will be updated. If false, block selection will not change. Defaults to true. + * + * @return {Object} Action object. + */ +export function replaceInnerBlocks( rootClientId, blocks, updateSelection = true ) { + return { + type: 'REPLACE_INNER_BLOCKS', + rootClientId, + blocks, + updateSelection, + time: Date.now(), + }; +} + /** * Returns an action object used to toggle the block editing mode between * visual and HTML modes. diff --git a/packages/block-editor/src/store/effects.js b/packages/block-editor/src/store/effects.js index ca46e1afb8f4a..d61ce67fe68df 100644 --- a/packages/block-editor/src/store/effects.js +++ b/packages/block-editor/src/store/effects.js @@ -17,14 +17,12 @@ import { replaceBlocks, selectBlock, setTemplateValidity, - insertDefaultBlock, resetBlocks, } from './actions'; import { getBlock, getBlocks, getSelectedBlockCount, - getBlockCount, getTemplateLock, getTemplate, isValidTemplate, @@ -60,23 +58,6 @@ export function validateBlocksToTemplate( action, store ) { } } -/** - * Effect handler which will return a default block insertion action if there - * are no other blocks at the root of the editor. This is expected to be used - * in actions which may result in no blocks remaining in the editor (removal, - * replacement, etc). - * - * @param {Object} action Action which had initiated the effect handler. - * @param {Object} store Store instance. - * - * @return {?Object} Default block insert action, if no other blocks exist. - */ -export function ensureDefaultBlock( action, store ) { - if ( ! getBlockCount( store.getState() ) ) { - return insertDefaultBlock(); - } -} - export default { MERGE_BLOCKS( action, store ) { const { dispatch } = store; @@ -127,9 +108,6 @@ export default { RESET_BLOCKS: [ validateBlocksToTemplate, ], - REPLACE_BLOCKS: [ - ensureDefaultBlock, - ], MULTI_SELECT: ( action, { getState } ) => { const blockCount = getSelectedBlockCount( getState() ); diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 007c5f4a8f6d4..0c7448d1a2a74 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -188,16 +188,6 @@ export function isUpdatingSameBlockAttribute( action, lastAction ) { function withPersistentBlockChange( reducer ) { let lastAction; - /** - * Set of action types for which a blocks state change should be considered - * non-persistent. - * - * @type {Set} - */ - const IGNORED_ACTION_TYPES = new Set( [ - 'RECEIVE_BLOCKS', - ] ); - return ( state, action ) => { let nextState = reducer( state, action ); @@ -206,19 +196,14 @@ function withPersistentBlockChange( reducer ) { // Defer to previous state value (or default) unless changing or // explicitly marking as persistent. if ( state === nextState && ! isExplicitPersistentChange ) { - return { - ...nextState, - isPersistentChange: get( state, [ 'isPersistentChange' ], true ), - }; - } + const nextIsPersistentChange = get( state, [ 'isPersistentChange' ], true ); + if ( state.isPersistentChange === nextIsPersistentChange ) { + return state; + } - // Some state changes should not be considered persistent, namely those - // which are not a direct result of user interaction. - const isIgnoredActionType = IGNORED_ACTION_TYPES.has( action.type ); - if ( isIgnoredActionType ) { return { ...nextState, - isPersistentChange: false, + isPersistentChange: nextIsPersistentChange, }; } @@ -239,6 +224,37 @@ function withPersistentBlockChange( reducer ) { }; } +/** + * Higher-order reducer intended to augment the blocks reducer, assigning an + * `isIgnoredChange` property value corresponding to whether a change in state + * can be considered as ignored. A change is considered ignored when the result + * of an action not incurred by direct user interaction. + * + * @param {Function} reducer Original reducer function. + * + * @return {Function} Enhanced reducer function. + */ +function withIgnoredBlockChange( reducer ) { + /** + * Set of action types for which a blocks state change should be ignored. + * + * @type {Set} + */ + const IGNORED_ACTION_TYPES = new Set( [ + 'RECEIVE_BLOCKS', + ] ); + + return ( state, action ) => { + const nextState = reducer( state, action ); + + if ( nextState !== state ) { + nextState.isIgnoredChange = IGNORED_ACTION_TYPES.has( action.type ); + } + + return nextState; + }; +} + /** * Higher-order reducer targeting the combined blocks reducer, augmenting * block client IDs in remove action to include cascade of inner blocks. @@ -296,6 +312,38 @@ const withBlockReset = ( reducer ) => ( state, action ) => { return reducer( state, action ); }; +/** + * Higher-order reducer which targets the combined blocks reducer and handles + * the `REPLACE_INNER_BLOCKS` action. When dispatched, this action the state should become equivalent + * to the execution of a `REMOVE_BLOCKS` action containing all the child's of the root block followed by + * the execution of `INSERT_BLOCKS` with the new blocks. + * + * @param {Function} reducer Original reducer function. + * + * @return {Function} Enhanced reducer function. + */ +const withReplaceInnerBlocks = ( reducer ) => ( state, action ) => { + if ( action.type !== 'REPLACE_INNER_BLOCKS' ) { + return reducer( state, action ); + } + let stateAfterBlocksRemoval = state; + if ( state.order[ action.rootClientId ] ) { + stateAfterBlocksRemoval = reducer( stateAfterBlocksRemoval, { + type: 'REMOVE_BLOCKS', + clientIds: state.order[ action.rootClientId ], + } ); + } + let stateAfterInsert = stateAfterBlocksRemoval; + if ( action.blocks.length ) { + stateAfterInsert = reducer( stateAfterInsert, { + ...action, + type: 'INSERT_BLOCKS', + index: 0, + } ); + } + return stateAfterInsert; +}; + /** * Higher-order reducer which targets the combined blocks reducer and handles * the `SAVE_REUSABLE_BLOCK_SUCCESS` action. This action can't be handled by @@ -344,9 +392,11 @@ const withSaveReusableBlock = ( reducer ) => ( state, action ) => { export const blocks = flow( combineReducers, withInnerBlocksRemoveCascade, + withReplaceInnerBlocks, // needs to be after withInnerBlocksRemoveCascade withBlockReset, withSaveReusableBlock, withPersistentBlockChange, + withIgnoredBlockChange, )( { byClientId( state = {}, action ) { switch ( action.type ) { @@ -713,6 +763,7 @@ export function blockSelection( state = { end: action.clientId, initialPosition: action.initialPosition, }; + case 'REPLACE_INNER_BLOCKS': // REPLACE_INNER_BLOCKS and INSERT_BLOCKS should follow the same logic. case 'INSERT_BLOCKS': { if ( action.updateSelection ) { return { diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index d1b142530cff6..657ac6f4116ab 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -1367,6 +1367,24 @@ export function isLastBlockChangePersistent( state ) { return state.blocks.isPersistentChange; } +/** + * Returns true if the most recent block change is be considered ignored, or + * false otherwise. An ignored change is one not to be committed by + * BlockEditorProvider, neither via `onChange` nor `onInput`. + * + * @param {Object} state Block editor state. + * + * @return {boolean} Whether the most recent block change was ignored. + */ +export function __unstableIsLastBlockChangeIgnored( state ) { + // TODO: Removal Plan: Changes incurred by RECEIVE_BLOCKS should not be + // ignored if in-fact they result in a change in blocks state. The current + // need to ignore changes not a result of user interaction should be + // accounted for in the refactoring of reusable blocks as occurring within + // their own separate block editor / state (#7119). + return state.blocks.isIgnoredChange; +} + /** * Returns the value of a post meta from the editor settings. * diff --git a/packages/block-editor/src/store/test/actions.js b/packages/block-editor/src/store/test/actions.js index 2ca6839dc57e2..1fb97cd38d9bd 100644 --- a/packages/block-editor/src/store/test/actions.js +++ b/packages/block-editor/src/store/test/actions.js @@ -2,30 +2,31 @@ * Internal dependencies */ import { - replaceBlocks, - startTyping, - stopTyping, + clearSelectedBlock, enterFormattedText, exitFormattedText, - toggleSelection, + hideInsertionPoint, + insertBlock, + insertBlocks, + mergeBlocks, + multiSelect, + removeBlock, + removeBlocks, + replaceBlock, + replaceBlocks, + replaceInnerBlocks, resetBlocks, - updateBlockAttributes, - updateBlock, selectBlock, selectPreviousBlock, + showInsertionPoint, startMultiSelect, + startTyping, stopMultiSelect, - multiSelect, - clearSelectedBlock, - replaceBlock, - insertBlock, - insertBlocks, - showInsertionPoint, - hideInsertionPoint, - mergeBlocks, - removeBlocks, - removeBlock, + stopTyping, toggleBlockMode, + toggleSelection, + updateBlock, + updateBlockAttributes, updateBlockListSettings, } from '../actions'; import { select } from '../controls'; @@ -116,65 +117,305 @@ describe( 'actions', () => { } ); describe( 'replaceBlock', () => { - it( 'should return the REPLACE_BLOCKS action', () => { + it( 'should yield the REPLACE_BLOCKS action if the new block can be inserted in the destination root block', () => { const block = { clientId: 'ribs', + name: 'core/test-block', }; - expect( replaceBlock( [ 'chicken' ], block ) ).toEqual( { + const replaceBlockGenerator = replaceBlock( 'chicken', block ); + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'chicken' ], + selectorName: 'getBlockRootClientId', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'core/test-block', undefined ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( true ).value, + ).toEqual( { type: 'REPLACE_BLOCKS', clientIds: [ 'chicken' ], blocks: [ block ], time: expect.any( Number ), } ); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [], + selectorName: 'getBlockCount', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( 1 ), + ).toEqual( { + value: undefined, + done: true, + } ); } ); } ); describe( 'replaceBlocks', () => { - it( 'should return the REPLACE_BLOCKS action', () => { + it( 'should not yield the REPLACE_BLOCKS action if the replacement is not possible', () => { + const blocks = [ { + clientId: 'ribs', + name: 'core/test-ribs', + }, { + clientId: 'chicken', + name: 'core/test-chicken', + } ]; + + const replaceBlockGenerator = replaceBlocks( [ 'chicken' ], blocks ); + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'chicken' ], + selectorName: 'getBlockRootClientId', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'core/test-ribs', undefined ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( true ).value, + ).toEqual( { + args: [ 'core/test-chicken', undefined ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( false ), + ).toEqual( { + value: undefined, + done: true, + } ); + } ); + + it( 'should yield the REPLACE_BLOCKS action if the all the replacement blocks can be inserted in the parent block', () => { const blocks = [ { clientId: 'ribs', + name: 'core/test-ribs', + }, { + clientId: 'chicken', + name: 'core/test-chicken', } ]; - expect( replaceBlocks( [ 'chicken' ], blocks ) ).toEqual( { + const replaceBlockGenerator = replaceBlocks( [ 'chicken' ], blocks ); + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'chicken' ], + selectorName: 'getBlockRootClientId', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'core/test-ribs', undefined ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( true ).value, + ).toEqual( { + args: [ 'core/test-chicken', undefined ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( true ).value, + ).toEqual( { type: 'REPLACE_BLOCKS', clientIds: [ 'chicken' ], blocks, time: expect.any( Number ), } ); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [], + selectorName: 'getBlockCount', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( 1 ), + ).toEqual( { + value: undefined, + done: true, + } ); } ); } ); describe( 'insertBlock', () => { - it( 'should return the INSERT_BLOCKS action', () => { + it( 'should yield the INSERT_BLOCKS action', () => { const block = { clientId: 'ribs', + name: 'core/test-block', }; const index = 5; - expect( insertBlock( block, index, 'testclientid' ) ).toEqual( { - type: 'INSERT_BLOCKS', - blocks: [ block ], - index, - rootClientId: 'testclientid', - time: expect.any( Number ), - updateSelection: true, + + const inserBlockGenerator = insertBlock( block, index, 'testclientid', true ); + expect( + inserBlockGenerator.next().value + ).toEqual( { + args: [ 'core/test-block', 'testclientid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + inserBlockGenerator.next( true ), + ).toEqual( { + done: true, + value: { + type: 'INSERT_BLOCKS', + blocks: [ block ], + index, + rootClientId: 'testclientid', + time: expect.any( Number ), + updateSelection: true, + }, } ); } ); } ); describe( 'insertBlocks', () => { - it( 'should return the INSERT_BLOCKS action', () => { - const blocks = [ { + it( 'should filter the allowed blocks in INSERT_BLOCKS action', () => { + const ribsBlock = { clientId: 'ribs', - } ]; - const index = 3; - expect( insertBlocks( blocks, index, 'testclientid' ) ).toEqual( { - type: 'INSERT_BLOCKS', - blocks, - index, - rootClientId: 'testclientid', - time: expect.any( Number ), - updateSelection: true, + name: 'core/test-ribs', + }; + const chickenBlock = { + clientId: 'chicken', + name: 'core/test-chicken', + }; + const chickenRibsBlock = { + clientId: 'chicken-ribs', + name: 'core/test-chicken-ribs', + }; + const blocks = [ + ribsBlock, + chickenBlock, + chickenRibsBlock, + ]; + + const inserBlockGenerator = insertBlocks( blocks, 5, 'testrootid', false ); + + expect( + inserBlockGenerator.next().value + ).toEqual( { + args: [ 'core/test-ribs', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + inserBlockGenerator.next( true ).value + ).toEqual( { + args: [ 'core/test-chicken', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + inserBlockGenerator.next( false ).value, + ).toEqual( { + args: [ 'core/test-chicken-ribs', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + inserBlockGenerator.next( true ), + ).toEqual( { + done: true, + value: { + type: 'INSERT_BLOCKS', + blocks: [ ribsBlock, chickenRibsBlock ], + index: 5, + rootClientId: 'testrootid', + time: expect.any( Number ), + updateSelection: false, + }, + } ); + } ); + + it( 'does not yield INSERT_BLOCKS action if all the blocks are impossible to insert', () => { + const ribsBlock = { + clientId: 'ribs', + name: 'core/test-ribs', + }; + const chickenBlock = { + clientId: 'chicken', + name: 'core/test-chicken', + }; + const blocks = [ + ribsBlock, + chickenBlock, + ]; + + const inserBlockGenerator = insertBlocks( blocks, 5, 'testrootid', false ); + + expect( + inserBlockGenerator.next().value + ).toEqual( { + args: [ 'core/test-ribs', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + inserBlockGenerator.next( false ).value, + ).toEqual( { + args: [ 'core/test-chicken', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + inserBlockGenerator.next( false ), + ).toEqual( { + done: true, + value: undefined, } ); } ); } ); @@ -346,4 +587,30 @@ describe( 'actions', () => { } ); } ); } ); + + describe( 'replaceInnerBlocks', () => { + const block = { + clientId: 'ribs', + }; + + it( 'should return the REPLACE_INNER_BLOCKS action with default values set', () => { + expect( replaceInnerBlocks( 'root', [ block ] ) ).toEqual( { + type: 'REPLACE_INNER_BLOCKS', + blocks: [ block ], + rootClientId: 'root', + time: expect.any( Number ), + updateSelection: true, + } ); + } ); + + it( 'should return the REPLACE_INNER_BLOCKS action with updateSelection false', () => { + expect( replaceInnerBlocks( 'root', [ block ], false ) ).toEqual( { + type: 'REPLACE_INNER_BLOCKS', + blocks: [ block ], + rootClientId: 'root', + time: expect.any( Number ), + updateSelection: false, + } ); + } ); + } ); } ); diff --git a/packages/block-editor/src/store/test/effects.js b/packages/block-editor/src/store/test/effects.js index 34300ed6d42ff..cdcf546077589 100644 --- a/packages/block-editor/src/store/test/effects.js +++ b/packages/block-editor/src/store/test/effects.js @@ -97,14 +97,19 @@ describe( 'effects', () => { expect( dispatch ).toHaveBeenCalledTimes( 2 ); expect( dispatch ).toHaveBeenCalledWith( selectBlock( 'chicken', -1 ) ); - expect( dispatch ).toHaveBeenCalledWith( { - ...replaceBlocks( [ 'chicken', 'ribs' ], [ { - clientId: 'chicken', - name: 'core/test-block', - attributes: { content: 'chicken ribs' }, - } ] ), - time: expect.any( Number ), - } ); + const lastCall = dispatch.mock.calls[ 1 ]; + expect( lastCall ).toHaveLength( 1 ); + const [ lastCallArgument ] = lastCall; + const expectedGenerator = replaceBlocks( [ 'chicken', 'ribs' ], [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken ribs' }, + } ] ); + expect( + Array.from( lastCallArgument ) + ).toEqual( + Array.from( expectedGenerator ) + ); } ); it( 'should not merge the blocks have different types without transformation', () => { @@ -195,14 +200,19 @@ describe( 'effects', () => { expect( dispatch ).toHaveBeenCalledTimes( 2 ); // expect( dispatch ).toHaveBeenCalledWith( focusBlock( 'chicken', { offset: -1 } ) ); - expect( dispatch ).toHaveBeenCalledWith( { - ...replaceBlocks( [ 'chicken', 'ribs' ], [ { - clientId: 'chicken', - name: 'core/test-block', - attributes: { content: 'chicken ribs' }, - } ] ), - time: expect.any( Number ), - } ); + const expectedGenerator = replaceBlocks( [ 'chicken', 'ribs' ], [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken ribs' }, + } ] ); + const lastCall = dispatch.mock.calls[ 1 ]; + expect( lastCall ).toHaveLength( 1 ); + const [ lastCallArgument ] = lastCall; + expect( + Array.from( lastCallArgument ) + ).toEqual( + Array.from( expectedGenerator ) + ); } ); } ); diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index 778875b99ef59..fe63fc34cd65b 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -162,6 +162,352 @@ describe( 'state', () => { unregisterBlockType( 'core/test-block' ); } ); + describe( 'replace inner blocks', () => { + beforeAll( () => { + registerBlockType( 'core/test-parent-block', { + save: noop, + edit: noop, + category: 'common', + title: 'test parent block', + } ); + registerBlockType( 'core/test-child-block', { + save: noop, + edit: noop, + category: 'common', + title: 'test child block 1', + attributes: { + attr: { + type: 'boolean', + }, + attr2: { + type: 'string', + }, + }, + } ); + } ); + + afterAll( () => { + unregisterBlockType( 'core/test-parent-block' ); + unregisterBlockType( 'core/test-child-block' ); + } ); + it( 'can replace a child block', () => { + const existingState = deepFreeze( { + byClientId: { + clicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + 'clicken-child': { + clientId: 'chicken-child', + name: 'core/test-child-block', + isValid: true, + }, + }, + attributes: { + clicken: {}, + 'clicken-child': { + attr: true, + }, + }, + order: { + '': [ 'clicken' ], + clicken: [ 'clicken-child' ], + 'clicken-child': [], + }, + } ); + + const newChildBlock = createBlock( 'core/test-child-block', { + attr: false, + attr2: 'perfect', + } ); + + const { clientId: newChildBlockId } = newChildBlock; + + const action = { + type: 'REPLACE_INNER_BLOCKS', + rootClientId: 'clicken', + blocks: [ newChildBlock ], + }; + + const state = blocks( existingState, action ); + + expect( state ).toEqual( { + isPersistentChange: true, + isIgnoredChange: false, + byClientId: { + clicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + [ newChildBlockId ]: { + clientId: newChildBlockId, + name: 'core/test-child-block', + isValid: true, + }, + }, + attributes: { + clicken: {}, + [ newChildBlockId ]: { + attr: false, + attr2: 'perfect', + }, + }, + order: { + '': [ 'clicken' ], + clicken: [ newChildBlockId ], + [ newChildBlockId ]: [], + }, + } ); + } ); + + it( 'can insert a child block', () => { + const existingState = deepFreeze( { + byClientId: { + clicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + }, + attributes: { + clicken: {}, + }, + order: { + '': [ 'clicken' ], + clicken: [], + }, + } ); + + const newChildBlock = createBlock( 'core/test-child-block', { + attr: false, + attr2: 'perfect', + } ); + + const { clientId: newChildBlockId } = newChildBlock; + + const action = { + type: 'REPLACE_INNER_BLOCKS', + rootClientId: 'clicken', + blocks: [ newChildBlock ], + }; + + const state = blocks( existingState, action ); + + expect( state ).toEqual( { + isPersistentChange: true, + isIgnoredChange: false, + byClientId: { + clicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + [ newChildBlockId ]: { + clientId: newChildBlockId, + name: 'core/test-child-block', + isValid: true, + }, + }, + attributes: { + clicken: {}, + [ newChildBlockId ]: { + attr: false, + attr2: 'perfect', + }, + }, + order: { + '': [ 'clicken' ], + clicken: [ newChildBlockId ], + [ newChildBlockId ]: [], + }, + } ); + } ); + + it( 'can replace multiple child blocks', () => { + const existingState = deepFreeze( { + byClientId: { + clicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + 'clicken-child': { + clientId: 'chicken-child', + name: 'core/test-child-block', + isValid: true, + }, + 'clicken-child-2': { + clientId: 'chicken-child', + name: 'core/test-child-block', + isValid: true, + }, + }, + attributes: { + clicken: {}, + 'clicken-child': { + attr: true, + }, + 'clicken-child-2': { + attr2: 'ok', + }, + }, + order: { + '': [ 'clicken' ], + clicken: [ 'clicken-child', 'clicken-child-2' ], + 'clicken-child': [], + 'clicken-child-2': [], + }, + } ); + + const newChildBlock1 = createBlock( 'core/test-child-block', { + attr: false, + attr2: 'perfect', + } ); + + const newChildBlock2 = createBlock( 'core/test-child-block', { + attr: true, + attr2: 'not-perfect', + } ); + + const newChildBlock3 = createBlock( 'core/test-child-block', { + attr2: 'hello', + } ); + + const { clientId: newChildBlockId1 } = newChildBlock1; + const { clientId: newChildBlockId2 } = newChildBlock2; + const { clientId: newChildBlockId3 } = newChildBlock3; + + const action = { + type: 'REPLACE_INNER_BLOCKS', + rootClientId: 'clicken', + blocks: [ newChildBlock1, newChildBlock2, newChildBlock3 ], + }; + + const state = blocks( existingState, action ); + + expect( state ).toEqual( { + isPersistentChange: true, + isIgnoredChange: false, + byClientId: { + clicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + [ newChildBlockId1 ]: { + clientId: newChildBlockId1, + name: 'core/test-child-block', + isValid: true, + }, + [ newChildBlockId2 ]: { + clientId: newChildBlockId2, + name: 'core/test-child-block', + isValid: true, + }, + [ newChildBlockId3 ]: { + clientId: newChildBlockId3, + name: 'core/test-child-block', + isValid: true, + }, + }, + attributes: { + clicken: {}, + [ newChildBlockId1 ]: { + attr: false, + attr2: 'perfect', + }, + [ newChildBlockId2 ]: { + attr: true, + attr2: 'not-perfect', + }, + [ newChildBlockId3 ]: { + attr2: 'hello', + }, + }, + order: { + '': [ 'clicken' ], + clicken: [ newChildBlockId1, newChildBlockId2, newChildBlockId3 ], + [ newChildBlockId1 ]: [], + [ newChildBlockId2 ]: [], + [ newChildBlockId3 ]: [], + }, + } ); + } ); + + it( 'can replace a child block that has other children', () => { + const existingState = deepFreeze( { + byClientId: { + clicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + 'clicken-child': { + clientId: 'chicken-child', + name: 'core/test-child-block', + isValid: true, + }, + 'clicken-grand-child': { + clientId: 'chicken-child', + name: 'core/test-block', + isValid: true, + }, + }, + attributes: { + clicken: {}, + 'clicken-child': {}, + 'clicken-grand-child': {}, + }, + order: { + '': [ 'clicken' ], + clicken: [ 'clicken-child' ], + 'clicken-child': [ 'clicken-grand-child' ], + 'clicken-grand-child': [], + }, + } ); + + const newChildBlock = createBlock( 'core/test-block' ); + + const { clientId: newChildBlockId } = newChildBlock; + + const action = { + type: 'REPLACE_INNER_BLOCKS', + rootClientId: 'clicken', + blocks: [ newChildBlock ], + }; + + const state = blocks( existingState, action ); + + expect( state ).toEqual( { + isPersistentChange: true, + isIgnoredChange: false, + byClientId: { + clicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + [ newChildBlockId ]: { + clientId: newChildBlockId, + name: 'core/test-block', + isValid: true, + }, + }, + attributes: { + clicken: {}, + [ newChildBlockId ]: {}, + }, + order: { + '': [ 'clicken' ], + clicken: [ newChildBlockId ], + [ newChildBlockId ]: [], + }, + } ); + } ); + } ); + it( 'should return empty byClientId, attributes, order by default', () => { const state = blocks( undefined, {} ); @@ -170,6 +516,7 @@ describe( 'state', () => { attributes: {}, order: {}, isPersistentChange: true, + isIgnoredChange: false, } ); } ); @@ -1158,7 +1505,22 @@ describe( 'state', () => { expect( state.isPersistentChange ).toBe( true ); } ); - it( 'should not consider received blocks as persistent change', () => { + it( 'should retain reference for same state, same persistence', () => { + const original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [], + } ) ); + + const state = blocks( original, { + type: '__INERT__', + } ); + + expect( state ).toBe( original ); + } ); + } ); + + describe( 'isIgnoredChange', () => { + it( 'should consider received blocks as ignored change', () => { const state = blocks( undefined, { type: 'RECEIVE_BLOCKS', blocks: [ { @@ -1168,7 +1530,7 @@ describe( 'state', () => { } ], } ); - expect( state.isPersistentChange ).toBe( false ); + expect( state.isIgnoredChange ).toBe( true ); } ); } ); } ); @@ -1562,6 +1924,55 @@ describe( 'state', () => { expect( state ).toBe( original ); } ); + + it( 'should update the selection on inner blocks replace if updateSelection is true', () => { + const original = deepFreeze( { + start: 'chicken', + end: 'chicken', + initialPosition: null, + isMultiSelecting: false, + } ); + const newBlock = { + name: 'core/test-block', + clientId: 'another-block', + }; + + const state = blockSelection( original, { + type: 'REPLACE_INNER_BLOCKS', + blocks: [ newBlock ], + rootClientId: 'parent', + updateSelection: true, + } ); + + expect( state ).toEqual( { + start: 'another-block', + end: 'another-block', + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should not update the selection on inner blocks replace if updateSelection is false', () => { + const original = deepFreeze( { + start: 'chicken', + end: 'chicken', + initialPosition: null, + isMultiSelecting: false, + } ); + const newBlock = { + name: 'core/test-block', + clientId: 'another-block', + }; + + const state = blockSelection( original, { + type: 'REPLACE_INNER_BLOCKS', + blocks: [ newBlock ], + rootClientId: 'parent', + updateSelection: false, + } ); + + expect( state ).toBe( original ); + } ); } ); describe( 'preferences()', () => { diff --git a/packages/block-library/src/button/editor.scss b/packages/block-library/src/button/editor.scss index 713e508bef055..1b8a089ba6789 100644 --- a/packages/block-library/src/button/editor.scss +++ b/packages/block-library/src/button/editor.scss @@ -40,7 +40,11 @@ .wp-block-button__link { max-width: 100%; overflow: hidden; - white-space: nowrap; + // Override is allowed here only because the rich text instance in + // a preview is not editable. + // To do: use the `save` function to preview a block transform, not + // the `edit` function. + white-space: nowrap !important; text-overflow: ellipsis; } } diff --git a/packages/block-library/src/button/style.scss b/packages/block-library/src/button/style.scss index 1519c6a11ddce..a373ff2e83c9b 100644 --- a/packages/block-library/src/button/style.scss +++ b/packages/block-library/src/button/style.scss @@ -27,7 +27,6 @@ $blocks-button__height: 56px; padding: 12px 24px; text-align: center; text-decoration: none; - white-space: normal; overflow-wrap: break-word; &:hover, diff --git a/packages/block-library/src/columns/editor.scss b/packages/block-library/src/columns/editor.scss index 2f316ef347154..346e2594669a3 100644 --- a/packages/block-library/src/columns/editor.scss +++ b/packages/block-library/src/columns/editor.scss @@ -112,26 +112,26 @@ } } +// The empty state of a columns block has the default appenders. +// Since those appenders are not blocks, the parent, actual block, appears "hovered" when hovering the appenders. +// Because the column shouldn't be hovered as part of this temporary passthrough, we unset the hover style. +.wp-block-columns [data-type="core/column"].is-hovered { + > .block-editor-block-list__block-edit::before { + content: none; + } + + .block-editor-block-list__breadcrumb { + display: none; + } +} + // In absence of making the individual columns resizable, we prevent them from being clickable. // This makes them less fiddly. @todo: This should be revisited as the interface is refined. .wp-block-columns [data-type="core/column"] { pointer-events: none; - // The empty state of a columns block has the default appenders. - // Since those appenders are not blocks, the parent, actual block, appears "hovered" when hovering the appenders. - // Because the column shouldn't be hovered as part of this temporary passthrough, we unset the hover style. - &.is-hovered { - > .block-editor-block-list__block-edit::before { - content: none; - } - - .block-editor-block-list__breadcrumb { - display: none; - } + // This selector re-enables clicking on any child of a column block. + .block-editor-block-list__layout { + pointer-events: all; } } - -// This selector re-enables clicking on any child of a column block. -:not(.components-disabled) > .wp-block-columns > .block-editor-inner-blocks > .block-editor-block-list__layout > [data-type="core/column"] > .block-editor-block-list__block-edit > * { - pointer-events: all; -} diff --git a/packages/block-library/src/gallery/editor.scss b/packages/block-library/src/gallery/editor.scss index 34b08797e2e55..1b0928205cb4f 100644 --- a/packages/block-library/src/gallery/editor.scss +++ b/packages/block-library/src/gallery/editor.scss @@ -12,7 +12,6 @@ ul.wp-block-gallery li { outline: none; } - img:focus, .is-selected { outline: 4px solid theme(primary); } diff --git a/packages/block-library/src/gallery/gallery-image.js b/packages/block-library/src/gallery/gallery-image.js index de18796926500..9deae12a3ca02 100644 --- a/packages/block-library/src/gallery/gallery-image.js +++ b/packages/block-library/src/gallery/gallery-image.js @@ -18,9 +18,9 @@ class GalleryImage extends Component { constructor() { super( ...arguments ); - this.onImageClick = this.onImageClick.bind( this ); + this.onSelectImage = this.onSelectImage.bind( this ); this.onSelectCaption = this.onSelectCaption.bind( this ); - this.onKeyDown = this.onKeyDown.bind( this ); + this.onRemoveImage = this.onRemoveImage.bind( this ); this.bindContainer = this.bindContainer.bind( this ); this.state = { @@ -44,7 +44,7 @@ class GalleryImage extends Component { } } - onImageClick() { + onSelectImage() { if ( ! this.props.isSelected ) { this.props.onSelect(); } @@ -56,7 +56,7 @@ class GalleryImage extends Component { } } - onKeyDown( event ) { + onRemoveImage( event ) { if ( this.container === document.activeElement && this.props.isSelected && [ BACKSPACE, DELETE ].indexOf( event.keyCode ) !== -1 @@ -108,10 +108,12 @@ class GalleryImage extends Component { src={ url } alt={ alt } data-id={ id } - onClick={ this.onImageClick } + onClick={ this.onSelectImage } + onFocus={ this.onSelectImage } + onKeyDown={ this.onRemoveImage } tabIndex="0" - onKeyDown={ this.onImageClick } aria-label={ ariaLabel } + ref={ this.bindContainer } /> { isBlobURL( url ) && } @@ -123,10 +125,8 @@ class GalleryImage extends Component { 'is-transient': isBlobURL( url ), } ); - // Disable reason: Each block can be selected by clicking on it and we should keep the same saved markup - /* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */ return ( -
+
{ isSelected &&
); - /* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */ } } diff --git a/packages/block-library/src/spacer/index.js b/packages/block-library/src/spacer/index.js index fd83148315454..7bf7dfd2cf873 100644 --- a/packages/block-library/src/spacer/index.js +++ b/packages/block-library/src/spacer/index.js @@ -6,7 +6,7 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { Fragment } from '@wordpress/element'; +import { Fragment, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { InspectorControls } from '@wordpress/block-editor'; import { BaseControl, PanelBody, ResizableBox, G, SVG, Path } from '@wordpress/components'; @@ -34,6 +34,7 @@ export const settings = { ( { attributes, isSelected, setAttributes, toggleSelection, instanceId } ) => { const { height } = attributes; const id = `block-spacer-height-input-${ instanceId }`; + const [ inputHeightValue, setInputHeightValue ] = useState( height ); return ( @@ -57,9 +58,11 @@ export const settings = { topLeft: false, } } onResizeStop={ ( event, direction, elt, delta ) => { + const spacerHeight = parseInt( height + delta.height, 10 ); setAttributes( { - height: parseInt( height + delta.height, 10 ), + height: spacerHeight, } ); + setInputHeightValue( spacerHeight ); toggleSelection( true ); } } onResizeStart={ () => { @@ -73,11 +76,21 @@ export const settings = { type="number" id={ id } onChange={ ( event ) => { + let spacerHeight = parseInt( event.target.value, 10 ); + setInputHeightValue( spacerHeight ); + if ( isNaN( spacerHeight ) ) { + // Set spacer height to default size and input box to empty string + setInputHeightValue( '' ); + spacerHeight = 100; + } else if ( spacerHeight < 20 ) { + // Set spacer height to minimum size + spacerHeight = 20; + } setAttributes( { - height: parseInt( event.target.value, 10 ), + height: spacerHeight, } ); } } - value={ height } + value={ inputHeightValue } min="20" step="10" /> diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index ab6e9ee6a141d..8b3c87efabf80 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -1,3 +1,9 @@ +## 7.3.0 (Unreleased) + +### New Features + +- Added a new `anchorRect` prop to `Popover` which enables a developer to provide a custom `DOMRect` object at which to position the popover. + ## 7.2.0 (2019-03-20) ### Improvements diff --git a/packages/components/src/button/style.scss b/packages/components/src/button/style.scss index 0f49a9625519b..b424745cb0556 100644 --- a/packages/components/src/button/style.scss +++ b/packages/components/src/button/style.scss @@ -175,7 +175,10 @@ @include button-style__focus-active; } - &.is-busy { + &.is-busy, + &.is-default.is-busy, + &.is-default.is-busy:disabled, + &.is-default.is-busy[aria-disabled="true"] { animation: components-button__busy-animation 2500ms infinite linear; background-size: 100px 100%; background-image: repeating-linear-gradient(-45deg, $light-gray-500, $white 11px, $white 10px, $light-gray-500 20px); diff --git a/packages/components/src/color-palette/style.scss b/packages/components/src/color-palette/style.scss index a2aace0b31b46..3b64489780dab 100644 --- a/packages/components/src/color-palette/style.scss +++ b/packages/components/src/color-palette/style.scss @@ -46,7 +46,8 @@ $color-palette-circle-spacing: 14px; &.is-active { box-shadow: inset 0 0 0 4px; - border: $border-width solid $dark-gray-400; + position: relative; + z-index: 1; & + .dashicons-saved { position: absolute; diff --git a/packages/components/src/higher-order/navigate-regions/index.js b/packages/components/src/higher-order/navigate-regions/index.js index ed5fb89dc7cf6..1d201be72e713 100644 --- a/packages/components/src/higher-order/navigate-regions/index.js +++ b/packages/components/src/higher-order/navigate-regions/index.js @@ -8,6 +8,7 @@ import classnames from 'classnames'; */ import { Component } from '@wordpress/element'; import { createHigherOrderComponent } from '@wordpress/compose'; +import { rawShortcut } from '@wordpress/keycodes'; /** * Internal dependencies @@ -67,9 +68,9 @@ export default createHigherOrderComponent( bindGlobal shortcuts={ { 'ctrl+`': this.focusNextRegion, - 'shift+alt+n': this.focusNextRegion, + [ rawShortcut.access( 'n' ) ]: this.focusNextRegion, 'ctrl+shift+`': this.focusPreviousRegion, - 'shift+alt+p': this.focusPreviousRegion, + [ rawShortcut.access( 'p' ) ]: this.focusPreviousRegion, } } /> diff --git a/packages/components/src/index.js b/packages/components/src/index.js index c5c34089cdb7c..65c44bb660ef3 100644 --- a/packages/components/src/index.js +++ b/packages/components/src/index.js @@ -43,7 +43,7 @@ export { default as PanelHeader } from './panel/header'; export { default as PanelRow } from './panel/row'; export { default as Placeholder } from './placeholder'; export { default as Popover } from './popover'; -export { default as PositionedAtSelection } from './positioned-at-selection'; +export { default as __unstablePositionedAtSelection } from './positioned-at-selection'; export { default as QueryControls } from './query-controls'; export { default as RadioControl } from './radio-control'; export { default as RangeControl } from './range-control'; diff --git a/packages/components/src/keyboard-shortcuts/index.js b/packages/components/src/keyboard-shortcuts/index.js index c086d922f348c..d4b12867e08b6 100644 --- a/packages/components/src/keyboard-shortcuts/index.js +++ b/packages/components/src/keyboard-shortcuts/index.js @@ -21,7 +21,22 @@ class KeyboardShortcuts extends Component { const { keyTarget = document } = this; this.mousetrap = new Mousetrap( keyTarget ); + forEach( this.props.shortcuts, ( callback, key ) => { + if ( process.env.NODE_ENV === 'development' ) { + const keys = key.split( '+' ); + const modifiers = new Set( keys.filter( ( value ) => value.length > 1 ) ); + const hasAlt = modifiers.has( 'alt' ); + const hasShift = modifiers.has( 'shift' ); + + if ( + ( modifiers.size === 1 && hasAlt ) || + ( modifiers.size === 2 && hasAlt && hasShift ) + ) { + throw new Error( `Cannot bind ${ key }. Alt and Shift+Alt modifiers are reserved for character input.` ); + } + } + const { bindGlobal, eventName } = this.props; const bindFn = bindGlobal ? 'bindGlobal' : 'bind'; this.mousetrap[ bindFn ]( key, callback, eventName ); diff --git a/packages/components/src/popover/README.md b/packages/components/src/popover/README.md index f071dbcad2a64..99097e08546b5 100644 --- a/packages/components/src/popover/README.md +++ b/packages/components/src/popover/README.md @@ -123,6 +123,13 @@ Opt-in prop to show popovers fullscreen on mobile, pass `false` in this prop to - Required: No - Default: `false` +### anchorRect + +A custom `DOMRect` object at which to position the popover. + +- Type: `DOMRect` +- Required: No + ## Methods ### refresh diff --git a/packages/components/src/popover/index.js b/packages/components/src/popover/index.js index 47212a3e12f9c..bdf1b5c0c7c62 100644 --- a/packages/components/src/popover/index.js +++ b/packages/components/src/popover/index.js @@ -69,7 +69,7 @@ class Popover extends Component { } componentDidMount() { - this.toggleAutoRefresh( true ); + this.toggleAutoRefresh( ! this.props.hasOwnProperty( 'anchorRect' ) ); this.refresh(); /* @@ -87,6 +87,15 @@ class Popover extends Component { if ( prevProps.position !== this.props.position ) { this.computePopoverPosition( this.state.popoverSize, this.anchorRect ); } + + if ( prevProps.anchorRect !== this.props.anchorRect ) { + this.refreshOnAnchorMove(); + } + + const hasAnchorRect = this.props.hasOwnProperty( 'anchorRect' ); + if ( hasAnchorRect !== prevProps.hasOwnProperty( 'anchorRect' ) ) { + this.toggleAutoRefresh( ! hasAnchorRect ); + } } componentWillUnmount() { @@ -129,8 +138,7 @@ class Popover extends Component { * will only refresh the popover position if the anchor moves. */ refreshOnAnchorMove() { - const { getAnchorRect = this.getAnchorRect } = this.props; - const anchorRect = getAnchorRect( this.anchorNode.current ); + const anchorRect = this.getAnchorRect( this.anchorNode.current ); const didAnchorRectChange = ! isShallowEqual( anchorRect, this.anchorRect ); if ( didAnchorRectChange ) { this.anchorRect = anchorRect; @@ -144,8 +152,7 @@ class Popover extends Component { * position. */ refresh() { - const { getAnchorRect = this.getAnchorRect } = this.props; - const anchorRect = getAnchorRect( this.anchorNode.current ); + const anchorRect = this.getAnchorRect( this.anchorNode.current ); const contentRect = this.contentNode.current.getBoundingClientRect(); const popoverSize = { width: contentRect.width, @@ -191,6 +198,16 @@ class Popover extends Component { } getAnchorRect( anchor ) { + const { getAnchorRect, anchorRect } = this.props; + + if ( anchorRect ) { + return anchorRect; + } + + if ( getAnchorRect ) { + return getAnchorRect( anchor ); + } + if ( ! anchor || ! anchor.parentNode ) { return; } diff --git a/packages/dom/src/dom.js b/packages/dom/src/dom.js index 87be698a3be8d..8be06daaaa320 100644 --- a/packages/dom/src/dom.js +++ b/packages/dom/src/dom.js @@ -100,7 +100,7 @@ function isEdge( container, isReverse, onlyVertical ) { } const computedStyle = window.getComputedStyle( container ); - const lineHeight = parseInt( computedStyle.lineHeight, 10 ); + const lineHeight = parseInt( computedStyle.lineHeight, 10 ) || 0; // Only consider the multiline selection at the edge if the direction is // towards the edge. @@ -112,6 +112,10 @@ function isEdge( container, isReverse, onlyVertical ) { return false; } + const padding = parseInt( computedStyle[ + `padding${ isReverse ? 'Top' : 'Bottom' }` + ], 10 ) || 0; + // 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 @@ -119,8 +123,8 @@ function isEdge( container, isReverse, onlyVertical ) { const buffer = 3 * parseInt( lineHeight, 10 ) / 4; const containerRect = container.getBoundingClientRect(); const verticalEdge = isReverse ? - containerRect.top > rangeRect.top - buffer : - containerRect.bottom < rangeRect.bottom + buffer; + containerRect.top + padding > rangeRect.top - buffer : + containerRect.bottom - padding < rangeRect.bottom + buffer; if ( ! verticalEdge ) { return false; 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 2a80f20633a95..0ea51c111b798 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`] = ` " `; +exports[`adding blocks should navigate contenteditable with padding 1`] = ` +" +

1

+ + + +

2

+" +`; + exports[`adding blocks should navigate empty paragraph 1`] = ` "

1

diff --git a/packages/e2e-tests/specs/change-detection.test.js b/packages/e2e-tests/specs/change-detection.test.js index 0e529a95ca512..7c9dab21d4d79 100644 --- a/packages/e2e-tests/specs/change-detection.test.js +++ b/packages/e2e-tests/specs/change-detection.test.js @@ -288,4 +288,21 @@ describe( 'Change detection', () => { await assertIsDirty( true ); } ); + + it( 'should not prompt when receiving reusable blocks', async () => { + // Regression Test: Verify that non-modifying behaviors does not incur + // dirtiness. Previously, this could occur as a result of either (a) + // selecting a block, (b) opening the inserter, or (c) editing a post + // which contained a reusable block. The root issue was changes in + // block editor state as a result of reusable blocks data having been + // received, reflected here in this test. + // + // TODO: This should be considered a temporary test, existing only so + // long as the experimental reusable blocks fetching data flow exists. + // + // See: https://github.com/WordPress/gutenberg/issues/14766 + await page.evaluate( () => window.wp.data.dispatch( 'core/editor' ).__experimentalReceiveReusableBlocks( [] ) ); + + await assertIsDirty( false ); + } ); } ); diff --git a/packages/e2e-tests/specs/preview.test.js b/packages/e2e-tests/specs/preview.test.js index 8488ffe1ec1e3..3379801446cbd 100644 --- a/packages/e2e-tests/specs/preview.test.js +++ b/packages/e2e-tests/specs/preview.test.js @@ -12,47 +12,79 @@ import { createURL, publishPost, saveDraft, + clickOnMoreMenuItem, + pressKeyWithModifier, } from '@wordpress/e2e-test-utils'; -describe( 'Preview', () => { - beforeEach( async () => { - await createNewPost(); - } ); +async function openPreviewPage( editorPage ) { + let openTabs = await browser.pages(); + const expectedTabsCount = openTabs.length + 1; + await editorPage.click( '.editor-post-preview' ); - async function openPreviewPage( editorPage ) { - let openTabs = await browser.pages(); - const expectedTabsCount = openTabs.length + 1; - await editorPage.click( '.editor-post-preview' ); - - // Wait for the new tab to open. - while ( openTabs.length < expectedTabsCount ) { - await editorPage.waitFor( 1 ); - openTabs = await browser.pages(); - } - - const previewPage = last( openTabs ); - // Wait for the preview to load. We can't do interstitial detection here, - // because it might load too quickly for us to pick up, so we wait for - // the preview to load by waiting for the title to appear. - await previewPage.waitForSelector( '.entry-title' ); - return previewPage; + // Wait for the new tab to open. + while ( openTabs.length < expectedTabsCount ) { + await editorPage.waitFor( 1 ); + openTabs = await browser.pages(); } - /** - * Given a Puppeteer Page instance for a preview window, clicks Preview, and - * awaits the window navigation. - * - * @param {puppeteer.Page} previewPage Page on which to await navigation. - * - * @return {Promise} Promise resolving once navigation completes. - */ - async function waitForPreviewNavigation( previewPage ) { - const navigationCompleted = previewPage.waitForNavigation(); - await page.click( '.editor-post-preview' ); - return navigationCompleted; + const previewPage = last( openTabs ); + // Wait for the preview to load. We can't do interstitial detection here, + // because it might load too quickly for us to pick up, so we wait for + // the preview to load by waiting for the title to appear. + await previewPage.waitForSelector( '.entry-title' ); + return previewPage; +} + +/** + * Given a Puppeteer Page instance for a preview window, clicks Preview, and + * awaits the window navigation. + * + * @param {puppeteer.Page} previewPage Page on which to await navigation. + * + * @return {Promise} Promise resolving once navigation completes. + */ +async function waitForPreviewNavigation( previewPage ) { + const navigationCompleted = previewPage.waitForNavigation(); + await page.click( '.editor-post-preview' ); + return navigationCompleted; +} + +/** + * Enables or disables the custom fields option. + * + * Note that this is implemented separately from the `toggleScreenOptions` + * utility, since the custom fields option triggers a page reload and requires + * extra async logic to wait for navigation to complete. + * + * @param {boolean} shouldBeChecked If true, turns the option on. If false, off. + */ +async function toggleCustomFieldsOption( shouldBeChecked ) { + const checkboxXPath = '//*[contains(@class, "edit-post-options-modal")]//label[contains(text(), "Custom Fields")]'; + await clickOnMoreMenuItem( 'Options' ); + await page.waitForXPath( checkboxXPath ); + const [ checkboxHandle ] = await page.$x( checkboxXPath ); + + const isChecked = await page.evaluate( + ( element ) => element.control.checked, + checkboxHandle + ); + + if ( isChecked !== shouldBeChecked ) { + const navigationCompleted = page.waitForNavigation(); + await checkboxHandle.click(); + await navigationCompleted; + return; } - it( 'Should open a preview window for a new post', async () => { + await page.click( '.edit-post-options-modal button[aria-label="Close dialog"]' ); +} + +describe( 'Preview', () => { + beforeEach( async () => { + await createNewPost(); + } ); + + it( 'should open a preview window for a new post', async () => { const editorPage = page; // Disabled until content present. @@ -129,7 +161,7 @@ describe( 'Preview', () => { await previewPage.close(); } ); - it( 'Should not revert title during a preview right after a save draft', async () => { + it( 'should not revert title during a preview right after a save draft', async () => { const editorPage = page; // Type aaaaa in the title filed. @@ -166,3 +198,61 @@ describe( 'Preview', () => { await previewPage.close(); } ); } ); + +describe( 'Preview with Custom Fields enabled', async () => { + beforeEach( async () => { + await createNewPost(); + await toggleCustomFieldsOption( true ); + } ); + + afterEach( async () => { + await toggleCustomFieldsOption( false ); + } ); + + // Catch regressions of https://github.com/WordPress/gutenberg/issues/12617 + it( 'displays edits to the post title and content in the preview', async () => { + const editorPage = page; + + // Add an initial title and content. + await editorPage.type( '.editor-post-title__input', 'title 1' ); + await editorPage.keyboard.press( 'Tab' ); + await editorPage.keyboard.type( 'content 1' ); + + // Publish the post and then close the publish panel. + await publishPost(); + await page.waitForSelector( '.editor-post-publish-panel' ); + await page.click( '.editor-post-publish-panel__header button' ); + + // Open the preview page. + const previewPage = await openPreviewPage( editorPage ); + + // Check the title and preview match. + let previewTitle = await previewPage.$eval( '.entry-title', ( node ) => node.textContent ); + expect( previewTitle ).toBe( 'title 1' ); + let previewContent = await previewPage.$eval( '.entry-content p', ( node ) => node.textContent ); + expect( previewContent ).toBe( 'content 1' ); + + // Return to editor and modify the title and content. + await editorPage.bringToFront(); + await editorPage.click( '.editor-post-title__input' ); + await pressKeyWithModifier( 'shift', 'Home' ); + await editorPage.keyboard.press( 'Delete' ); + await editorPage.keyboard.type( 'title 2' ); + await editorPage.keyboard.press( 'Tab' ); + await pressKeyWithModifier( 'shift', 'Home' ); + await editorPage.keyboard.press( 'Delete' ); + await editorPage.keyboard.type( 'content 2' ); + + // Open the preview page. + await waitForPreviewNavigation( previewPage ); + + // Title in preview should match input. + previewTitle = await previewPage.$eval( '.entry-title', ( node ) => node.textContent ); + expect( previewTitle ).toBe( 'title 2' ); + previewContent = await previewPage.$eval( '.entry-content p', ( node ) => node.textContent ); + expect( previewContent ).toBe( 'content 2' ); + + // Make sure the editor is active for the afterEach function. + await editorPage.bringToFront(); + } ); +} ); diff --git a/packages/e2e-tests/specs/writing-flow.test.js b/packages/e2e-tests/specs/writing-flow.test.js index c186507a7cdef..c3c4d477e1378 100644 --- a/packages/e2e-tests/specs/writing-flow.test.js +++ b/packages/e2e-tests/specs/writing-flow.test.js @@ -310,4 +310,21 @@ describe( 'adding blocks', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + + it( 'should navigate contenteditable with padding', async () => { + await clickBlockAppender(); + await page.keyboard.press( 'Enter' ); + await page.evaluate( () => { + document.activeElement.style.paddingTop = '100px'; + } ); + await page.keyboard.press( 'ArrowUp' ); + await page.keyboard.type( '1' ); + await page.evaluate( () => { + document.activeElement.style.paddingBottom = '100px'; + } ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.type( '2' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); } ); diff --git a/packages/edit-post/src/components/header/style.scss b/packages/edit-post/src/components/header/style.scss index 6676d35676b7d..2cebe45d53f7c 100644 --- a/packages/edit-post/src/components/header/style.scss +++ b/packages/edit-post/src/components/header/style.scss @@ -68,26 +68,17 @@ // Header toggle buttons. &.is-toggled { color: $white; - } - - // Put the gray background on a separate layer, so as to match the size of the publish button (34px). - &.is-toggled::before { - content: ""; - border-radius: $radius-round-rectangle; - position: absolute; - z-index: -1; background: $dark-gray-500; - top: 1px; - right: 1px; - bottom: 1px; - left: 1px; + margin: 1px; + padding: 7px; } + // The !important in this ruleset need to override the pile of :not() selectors used in the icon-button. &.is-toggled:hover, &.is-toggled:focus { - box-shadow: 0 0 0 $border-width $dark-gray-500, inset 0 0 0 $border-width $white; - color: $white; - background: $dark-gray-500; + box-shadow: 0 0 0 $border-width $dark-gray-500, inset 0 0 0 $border-width $white !important; + color: $white !important; + background: $dark-gray-500 !important; } // Make editor header bar buttons bigger to match IconButtons. diff --git a/packages/edit-post/src/components/keyboard-shortcut-help-modal/config.js b/packages/edit-post/src/components/keyboard-shortcut-help-modal/config.js index 22f160b7727d8..5c6037c0467f4 100644 --- a/packages/edit-post/src/components/keyboard-shortcut-help-modal/config.js +++ b/packages/edit-post/src/components/keyboard-shortcut-help-modal/config.js @@ -18,7 +18,6 @@ const { ctrl, alt, ctrlShift, - shiftAlt, } = displayShortcutList; const globalShortcuts = { @@ -60,11 +59,11 @@ const globalShortcuts = { ariaLabel: shortcutAriaLabel.ctrlShift( '`' ), }, { - keyCombination: shiftAlt( 'n' ), + keyCombination: access( 'n' ), description: __( 'Navigate to the next part of the editor (alternative).' ), }, { - keyCombination: shiftAlt( 'p' ), + keyCombination: access( 'p' ), description: __( 'Navigate to the previous part of the editor (alternative).' ), }, { diff --git a/packages/edit-post/src/components/text-editor/style.scss b/packages/edit-post/src/components/text-editor/style.scss index e925344dc9e3a..d7f3eacd2a23d 100644 --- a/packages/edit-post/src/components/text-editor/style.scss +++ b/packages/edit-post/src/components/text-editor/style.scss @@ -34,6 +34,13 @@ padding: $block-padding; } + // Hide the thick left border in the code editor. + &:not(.is-focus-mode):not(.has-fixed-toolbar):not(.is-selected) .editor-post-title__input:hover, + &:not(.is-focus-mode):not(.has-fixed-toolbar).is-selected .editor-post-title__input { + box-shadow: none; + border-left-width: $border-width; + } + textarea:hover, &.is-selected textarea { box-shadow: 0 0 0 $border-width $light-gray-500; @@ -41,9 +48,12 @@ } .editor-post-permalink { - left: 0; - right: 0; margin-top: -6px; + + // Hide the thick left border in the code editor. + box-shadow: none; + border: none; + outline: $border-width solid $light-gray-800; } @include break-small() { diff --git a/packages/edit-post/src/store/effects.js b/packages/edit-post/src/store/effects.js index 673025c3fae51..4897b75b2060f 100644 --- a/packages/edit-post/src/store/effects.js +++ b/packages/edit-post/src/store/effects.js @@ -45,26 +45,22 @@ const effects = { let wasSavingPost = select( 'core/editor' ).isSavingPost(); let wasAutosavingPost = select( 'core/editor' ).isAutosavingPost(); - let wasPreviewingPost = select( 'core/editor' ).isPreviewingPost(); // Save metaboxes when performing a full save on the post. subscribe( () => { const isSavingPost = select( 'core/editor' ).isSavingPost(); const isAutosavingPost = select( 'core/editor' ).isAutosavingPost(); - const isPreviewingPost = select( 'core/editor' ).isPreviewingPost(); const hasActiveMetaBoxes = select( 'core/edit-post' ).hasMetaBoxes(); // Save metaboxes on save completion, except for autosaves that are not a post preview. const shouldTriggerMetaboxesSave = ( hasActiveMetaBoxes && ( - ( wasSavingPost && ! isSavingPost && ! wasAutosavingPost ) || - ( wasAutosavingPost && wasPreviewingPost && ! isPreviewingPost ) + ( wasSavingPost && ! isSavingPost && ! wasAutosavingPost ) ) ); // Save current state for next inspection. wasSavingPost = isSavingPost; wasAutosavingPost = isAutosavingPost; - wasPreviewingPost = isPreviewingPost; if ( shouldTriggerMetaboxesSave ) { store.dispatch( requestMetaBoxUpdates() ); diff --git a/packages/editor/src/components/autocompleters/style.scss b/packages/editor/src/components/autocompleters/style.scss index a173c1f0b5e36..d41748a13d27e 100644 --- a/packages/editor/src/components/autocompleters/style.scss +++ b/packages/editor/src/components/autocompleters/style.scss @@ -1,12 +1,11 @@ - -.block-editor-autocompleters__block { - .block-editor-block-icon { +.editor-autocompleters__block { + .editor-block-icon { margin-right: 8px; } } -.block-editor-autocompleters__user { - .block-editor-autocompleters__user-avatar { +.editor-autocompleters__user { + .editor-autocompleters__user-avatar { margin-right: 8px; flex-grow: 0; flex-shrink: 0; @@ -14,7 +13,7 @@ width: 24px; // avoid jarring resize by seting the size upfront height: 24px; } - .block-editor-autocompleters__user-name { + .editor-autocompleters__user-name { white-space: nowrap; text-overflow: ellipsis; overflow: hidden; @@ -22,7 +21,7 @@ flex-shrink: 0; flex-grow: 1; } - .block-editor-autocompleters__user-slug { + .editor-autocompleters__user-slug { margin-left: 8px; color: $dark-gray-100; white-space: nowrap; @@ -32,7 +31,7 @@ flex-grow: 0; flex-shrink: 0; } - &:hover .block-editor-autocompleters__user-slug { + &:hover .editor-autocompleters__user-slug { color: $blue-medium-300; } } diff --git a/packages/format-library/src/image/index.js b/packages/format-library/src/image/index.js index 0433941fa4b25..2e2bc942de76b 100644 --- a/packages/format-library/src/image/index.js +++ b/packages/format-library/src/image/index.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { Path, SVG, TextControl, Popover, IconButton, PositionedAtSelection } from '@wordpress/components'; +import { Path, SVG, TextControl, Popover, IconButton, __unstablePositionedAtSelection } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { Component } from '@wordpress/element'; import { insertObject } from '@wordpress/rich-text'; @@ -113,7 +113,7 @@ export const image = { return null; } } /> } - { isObjectActive && + { isObjectActive && <__unstablePositionedAtSelection key={ key }> { /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ } - } + } ); } diff --git a/packages/format-library/src/link/inline.js b/packages/format-library/src/link/inline.js index 58b38bed2c64e..e94ce68514fb3 100644 --- a/packages/format-library/src/link/inline.js +++ b/packages/format-library/src/link/inline.js @@ -7,15 +7,15 @@ import classnames from 'classnames'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { Component, createRef } from '@wordpress/element'; +import { Component, createRef, useMemo } from '@wordpress/element'; import { ExternalLink, IconButton, ToggleControl, withSpokenMessages, - PositionedAtSelection, } from '@wordpress/components'; import { LEFT, RIGHT, UP, DOWN, BACKSPACE, ENTER } from '@wordpress/keycodes'; +import { getRectangleFromRange } from '@wordpress/dom'; import { prependHTTP, safeDecodeURI, filterURLForDisplay } from '@wordpress/url'; import { create, @@ -77,6 +77,39 @@ const LinkViewerUrl = ( { url } ) => { ); }; +const URLPopoverAtLink = ( { isActive, addingLink, value, ...props } ) => { + const anchorRect = useMemo( () => { + const range = window.getSelection().getRangeAt( 0 ); + if ( ! range ) { + return; + } + + if ( addingLink ) { + return getRectangleFromRange( range ); + } + + let element = range.startContainer; + + // If the caret is right before the element, select the next element. + element = element.nextElementSibling || element; + + while ( element.nodeType !== window.Node.ELEMENT_NODE ) { + element = element.parentNode; + } + + const closest = element.closest( 'a' ); + if ( closest ) { + return closest.getBoundingClientRect(); + } + }, [ isActive, addingLink, value.start, value.end ] ); + + if ( ! anchorRect ) { + return null; + } + + return ; +}; + const LinkViewer = ( { url, editLink } ) => { return ( // Disable reason: KeyPress must be suppressed so the block doesn't hide the toolbar @@ -221,37 +254,36 @@ class InlineLinkUI extends Component { const showInput = isShowingInput( this.props, this.state ); return ( - ( + + ) } > - ( - - ) } - > - { showInput ? ( - - ) : ( - - ) } - - + { showInput ? ( + + ) : ( + + ) } + ); } }