diff --git a/docs/explanations/faq.md b/docs/explanations/faq.md index 4a365ce5b41d19..7cd54f584968eb 100644 --- a/docs/explanations/faq.md +++ b/docs/explanations/faq.md @@ -219,6 +219,11 @@ This is the canonical list of keyboard shortcuts: Esc Esc + + Select text across multiple blocks. + + Shift+Arrow (⇦, ⇧, ⇨, ⇩) + diff --git a/packages/block-editor/src/components/keyboard-shortcuts/index.js b/packages/block-editor/src/components/keyboard-shortcuts/index.js index ae41f75e473fca..0e0a57257becca 100644 --- a/packages/block-editor/src/components/keyboard-shortcuts/index.js +++ b/packages/block-editor/src/components/keyboard-shortcuts/index.js @@ -93,6 +93,16 @@ function KeyboardShortcutsRegister() { }, } ); + registerShortcut( { + name: 'core/block-editor/multi-text-selection', + category: 'selection', + description: __( 'Select text across multiple blocks.' ), + keyCombination: { + modifier: 'shift', + character: 'arrow', + }, + } ); + registerShortcut( { name: 'core/block-editor/focus-toolbar', category: 'global', diff --git a/packages/block-library/src/column/edit.js b/packages/block-library/src/column/edit.js index 84a230ca6e79ac..88169b655a787c 100644 --- a/packages/block-library/src/column/edit.js +++ b/packages/block-library/src/column/edit.js @@ -104,6 +104,7 @@ function ColumnEdit( { diff --git a/packages/block-library/src/columns/style.scss b/packages/block-library/src/columns/style.scss index 96fbf26009a4ab..f7e73c81ea794c 100644 --- a/packages/block-library/src/columns/style.scss +++ b/packages/block-library/src/columns/style.scss @@ -109,6 +109,10 @@ align-self: flex-end; } + &.is-vertically-aligned-stretch { + align-self: stretch; + } + &.is-vertically-aligned-top, &.is-vertically-aligned-center, &.is-vertically-aligned-bottom { diff --git a/packages/block-library/src/details/block.json b/packages/block-library/src/details/block.json index 421a4d957e3e63..3c87a70117d09d 100644 --- a/packages/block-library/src/details/block.json +++ b/packages/block-library/src/details/block.json @@ -13,7 +13,9 @@ "default": false }, "summary": { - "type": "string" + "type": "string", + "source": "html", + "selector": "summary" } }, "supports": { diff --git a/packages/core-data/src/entity-provider.js b/packages/core-data/src/entity-provider.js index cf15c423456a29..d32b3853627b32 100644 --- a/packages/core-data/src/entity-provider.js +++ b/packages/core-data/src/entity-provider.js @@ -142,7 +142,7 @@ export function useEntityProp( kind, name, prop, _id ) { * The return value has the shape `[ blocks, onInput, onChange ]`. * `onInput` is for block changes that don't create undo levels * or dirty the post, non-persistent changes, and `onChange` is for - * peristent changes. They map directly to the props of a + * persistent changes. They map directly to the props of a * `BlockEditorProvider` and are intended to be used with it, * or similar components or hooks. * @@ -290,7 +290,7 @@ export function useEntityBlockEditor( kind, name, { id: _id } = {} ) { } ); } - // We need to go through all block attributs deeply and update the + // We need to go through all block attributes deeply and update the // footnote anchor numbering (textContent) to match the new order. const newBlocks = updateBlocksAttributes( _blocks ); diff --git a/packages/core-data/src/test/entity-provider.js b/packages/core-data/src/test/entity-provider.js new file mode 100644 index 00000000000000..728630b482626c --- /dev/null +++ b/packages/core-data/src/test/entity-provider.js @@ -0,0 +1,272 @@ +/** + * External dependencies + */ +import { act, render } from '@testing-library/react'; + +/** + * WordPress dependencies + */ +import { + createBlock, + registerBlockType, + unregisterBlockType, + getBlockTypes, +} from '@wordpress/blocks'; +import { RichText, useBlockProps } from '@wordpress/block-editor'; +import { createRegistry, RegistryProvider } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as coreDataStore } from '../index'; +import { useEntityBlockEditor } from '../entity-provider'; + +const postTypeConfig = { + kind: 'postType', + name: 'post', + baseURL: '/wp/v2/posts', + transientEdits: { blocks: true, selection: true }, + mergedEdits: { meta: true }, + rawAttributes: [ 'title', 'excerpt', 'content' ], +}; + +const postTypeEntity = { + slug: 'post', + rest_base: 'posts', + labels: { + item_updated: 'Updated Post', + item_published: 'Post published', + item_reverted_to_draft: 'Post reverted to draft.', + }, +}; + +const aSinglePost = { + id: 1, + type: 'post', + content: { + raw: '

apples

oranges

A paragraph

', + rendered: '

A paragraph

', + }, + meta: { + footnotes: '[]', + }, +}; + +function createRegistryWithStores() { + // Create a registry. + const registry = createRegistry(); + + // Register store. + registry.register( coreDataStore ); + + // Register post type entity. + registry.dispatch( coreDataStore ).addEntities( [ postTypeConfig ] ); + + // Store post type entity. + registry + .dispatch( coreDataStore ) + .receiveEntityRecords( 'root', 'postType', [ postTypeEntity ] ); + + // Store a single post for use by the tests. + registry + .dispatch( coreDataStore ) + .receiveEntityRecords( 'postType', 'post', [ aSinglePost ] ); + + return registry; +} + +describe( 'useEntityBlockEditor', () => { + let registry; + + beforeEach( () => { + registry = createRegistryWithStores(); + + const edit = ( { children } ) => <>{ children }; + + registerBlockType( 'core/test-block', { + supports: { + className: false, + }, + save: ( { attributes } ) => { + const { content } = attributes; + return ( +

+ +

+ ); + }, + category: 'text', + attributes: { + content: { + type: 'string', + source: 'html', + selector: 'p', + default: '', + __experimentalRole: 'content', + }, + }, + title: 'block title', + edit, + } ); + + registerBlockType( 'core/test-block-with-array-of-strings', { + supports: { + className: false, + }, + save: ( { attributes } ) => { + const { items } = attributes; + return ( +
+ { items.map( ( item, index ) => ( +

{ item }

+ ) ) } +
+ ); + }, + category: 'text', + attributes: { + items: { + type: 'array', + items: { + type: 'string', + }, + default: [ 'apples', null, 'oranges' ], + }, + }, + title: 'block title', + edit, + } ); + } ); + + afterEach( () => { + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + } ); + + it( 'does not mutate block attributes that include an array of strings or null values', async () => { + let blocks, onChange; + const TestComponent = () => { + [ blocks, , onChange ] = useEntityBlockEditor( 'postType', 'post', { + id: 1, + } ); + + return
; + }; + + render( + + + + ); + + expect( blocks[ 0 ].name ).toEqual( + 'core/test-block-with-array-of-strings' + ); + expect( blocks[ 0 ].attributes.items ).toEqual( [ + 'apples', + null, + 'oranges', + ] ); + + // Add a block with content that will match against footnotes logic, causing + // `updateFootnotes` to iterate over blocks and their attributes. + act( () => { + onChange( + [ + ...blocks, + createBlock( 'core/test-block', { + content: + '

1

', + } ), + ], + { + selection: { + selectionStart: {}, + selectionEnd: {}, + initialPosition: {}, + }, + } + ); + } ); + + // Ensure the first block remains the same, with unaltered attributes. + expect( blocks[ 0 ].name ).toEqual( + 'core/test-block-with-array-of-strings' + ); + expect( blocks[ 0 ].attributes.items ).toEqual( [ + 'apples', + null, + 'oranges', + ] ); + } ); + + it( 'updates the order of footnotes when a new footnote is inserted', async () => { + // Start with a post containing a block with a single footnote (set to 1). + registry + .dispatch( coreDataStore ) + .receiveEntityRecords( 'postType', 'post', [ + { + id: 1, + type: 'post', + content: { + raw: '

A paragraph1

', + rendered: '

A paragraph

', + }, + meta: { + footnotes: '[]', + }, + }, + ] ); + + let blocks, onChange; + + const TestComponent = () => { + [ blocks, , onChange ] = useEntityBlockEditor( 'postType', 'post', { + id: 1, + } ); + + return
; + }; + + render( + + + + ); + + // The first block should have the footnote number 1. + expect( blocks[ 0 ].attributes.content ).toEqual( + 'A paragraph1' + ); + + // Add a block with a new footnote with an arbitrary footnote number that will be overwritten after insertion. + act( () => { + onChange( + [ + createBlock( 'core/test-block', { + content: + 'A new paragraph999', + } ), + ...blocks, + ], + { + selection: { + selectionStart: {}, + selectionEnd: {}, + initialPosition: {}, + }, + } + ); + } ); + + // The newly inserted block should have the footnote number 1, and the + // existing footnote number 1 should be updated to 2. + expect( blocks[ 0 ].attributes.content ).toEqual( + 'A new paragraph1' + ); + expect( blocks[ 1 ].attributes.content ).toEqual( + 'A paragraph2' + ); + } ); +} ); diff --git a/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap b/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap index 2ad8e457bae303..b98bd562f0a6a3 100644 --- a/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap +++ b/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap @@ -272,6 +272,35 @@ exports[`KeyboardShortcutHelpModal should match snapshot when the modal is activ
+
  • +
    + Select text across multiple blocks. +
    +
    + + + Shift + + + + + Arrow + + +
    +
  • { +function PageAttributesOrder() { + const order = useSelect( + ( select ) => + select( editorStore ).getEditedPostAttribute( 'menu_order' ) ?? 0, + [] + ); + const { editPost } = useDispatch( editorStore ); const [ orderInput, setOrderInput ] = useState( null ); const setUpdatedOrder = ( value ) => { setOrderInput( value ); const newOrder = Number( value ); if ( Number.isInteger( newOrder ) && value.trim?.() !== '' ) { - onUpdateOrder( Number( value ) ); + editPost( { menu_order: newOrder } ); } }; - const value = orderInput === null ? order : orderInput; + + const value = orderInput ?? order; + return ( @@ -43,27 +50,12 @@ export const PageAttributesOrder = ( { onUpdateOrder, order = 0 } ) => { ); -}; +} -function PageAttributesOrderWithChecks( props ) { +export default function PageAttributesOrderWithChecks() { return ( - + ); } - -export default compose( [ - withSelect( ( select ) => { - return { - order: select( editorStore ).getEditedPostAttribute( 'menu_order' ), - }; - } ), - withDispatch( ( dispatch ) => ( { - onUpdateOrder( order ) { - dispatch( editorStore ).editPost( { - menu_order: order, - } ); - }, - } ) ), -] )( PageAttributesOrderWithChecks ); diff --git a/packages/editor/src/components/page-attributes/test/order.js b/packages/editor/src/components/page-attributes/test/order.js index 01b0bd269e07cf..245cbbb8fc71db 100644 --- a/packages/editor/src/components/page-attributes/test/order.js +++ b/packages/editor/src/components/page-attributes/test/order.js @@ -4,10 +4,43 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; + /** * Internal dependencies */ -import { PageAttributesOrder } from '../order'; +import PageAttributesOrder from '../order'; + +jest.mock( '@wordpress/data/src/components/use-select', () => jest.fn() ); +jest.mock( '@wordpress/data/src/components/use-dispatch', () => ( { + useDispatch: jest.fn(), +} ) ); + +function setupDataMock( order = 0 ) { + useSelect.mockImplementation( ( mapSelect ) => + mapSelect( () => ( { + getPostType: () => null, + getEditedPostAttribute: ( attr ) => { + switch ( attr ) { + case 'menu_order': + return order; + default: + return null; + } + }, + } ) ) + ); + + const editPost = jest.fn(); + useDispatch.mockImplementation( () => ( { + editPost, + } ) ); + + return editPost; +} describe( 'PageAttributesOrder', () => { /** @@ -22,9 +55,9 @@ describe( 'PageAttributesOrder', () => { it( 'should reject invalid input', async () => { const user = userEvent.setup(); - const onUpdateOrder = jest.fn(); + const editPost = setupDataMock(); - render( ); + render( ); const input = screen.getByRole( 'spinbutton', { name: 'Order' } ); await user.type( input, 'bad', typeOptions ); @@ -33,31 +66,29 @@ describe( 'PageAttributesOrder', () => { await user.type( input, '+', typeOptions ); await user.type( input, ' ', typeOptions ); - expect( onUpdateOrder ).not.toHaveBeenCalled(); + expect( editPost ).not.toHaveBeenCalled(); } ); it( 'should update with zero input', async () => { const user = userEvent.setup(); - const onUpdateOrder = jest.fn(); + const editPost = setupDataMock( 4 ); - render( - - ); + render( ); const input = screen.getByRole( 'spinbutton', { name: 'Order' } ); await user.type( input, '0', typeOptions ); - expect( onUpdateOrder ).toHaveBeenCalledWith( 0 ); + expect( editPost ).toHaveBeenCalledWith( { menu_order: 0 } ); } ); it( 'should update with valid positive input', async () => { const user = userEvent.setup(); - const onUpdateOrder = jest.fn(); + const editPost = setupDataMock(); - render( ); + render( ); await user.type( screen.getByRole( 'spinbutton', { name: 'Order' } ), @@ -65,15 +96,15 @@ describe( 'PageAttributesOrder', () => { typeOptions ); - expect( onUpdateOrder ).toHaveBeenCalledWith( 4 ); + expect( editPost ).toHaveBeenCalledWith( { menu_order: 4 } ); } ); it( 'should update with valid negative input', async () => { const user = userEvent.setup(); - const onUpdateOrder = jest.fn(); + const editPost = setupDataMock(); - render( ); + render( ); await user.type( screen.getByRole( 'spinbutton', { name: 'Order' } ), @@ -81,6 +112,6 @@ describe( 'PageAttributesOrder', () => { typeOptions ); - expect( onUpdateOrder ).toHaveBeenCalledWith( -1 ); + expect( editPost ).toHaveBeenCalledWith( { menu_order: -1 } ); } ); } ); diff --git a/packages/editor/src/components/post-excerpt/check.js b/packages/editor/src/components/post-excerpt/check.js index a94a5badec180c..7d77ba77cd029a 100644 --- a/packages/editor/src/components/post-excerpt/check.js +++ b/packages/editor/src/components/post-excerpt/check.js @@ -3,8 +3,12 @@ */ import PostTypeSupportCheck from '../post-type-support-check'; -function PostExcerptCheck( props ) { - return ; +function PostExcerptCheck( { children } ) { + return ( + + { children } + + ); } export default PostExcerptCheck; diff --git a/packages/editor/src/components/post-featured-image/check.js b/packages/editor/src/components/post-featured-image/check.js index 5481deaa81604b..24a9f80058ddcc 100644 --- a/packages/editor/src/components/post-featured-image/check.js +++ b/packages/editor/src/components/post-featured-image/check.js @@ -4,10 +4,12 @@ import PostTypeSupportCheck from '../post-type-support-check'; import ThemeSupportCheck from '../theme-support-check'; -function PostFeaturedImageCheck( props ) { +function PostFeaturedImageCheck( { children } ) { return ( - + + { children } + ); } diff --git a/packages/editor/src/components/post-format/check.js b/packages/editor/src/components/post-format/check.js index cc65bde0cdec24..0810c9d613aae4 100644 --- a/packages/editor/src/components/post-format/check.js +++ b/packages/editor/src/components/post-format/check.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { withSelect } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -9,17 +9,22 @@ import { withSelect } from '@wordpress/data'; import PostTypeSupportCheck from '../post-type-support-check'; import { store as editorStore } from '../../store'; -function PostFormatCheck( { disablePostFormats, ...props } ) { +function PostFormatCheck( { children } ) { + const disablePostFormats = useSelect( + ( select ) => + select( editorStore ).getEditorSettings().disablePostFormats, + [] + ); + + if ( disablePostFormats ) { + return null; + } + return ( - ! disablePostFormats && ( - - ) + + { children } + ); } -export default withSelect( ( select ) => { - const editorSettings = select( editorStore ).getEditorSettings(); - return { - disablePostFormats: editorSettings.disablePostFormats, - }; -} )( PostFormatCheck ); +export default PostFormatCheck; diff --git a/packages/editor/src/components/post-last-revision/check.js b/packages/editor/src/components/post-last-revision/check.js index eddb917db76d47..44f96b9cf7acb0 100644 --- a/packages/editor/src/components/post-last-revision/check.js +++ b/packages/editor/src/components/post-last-revision/check.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { withSelect } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -9,11 +9,16 @@ import { withSelect } from '@wordpress/data'; import PostTypeSupportCheck from '../post-type-support-check'; import { store as editorStore } from '../../store'; -export function PostLastRevisionCheck( { - lastRevisionId, - revisionsCount, - children, -} ) { +function PostLastRevisionCheck( { children } ) { + const { lastRevisionId, revisionsCount } = useSelect( ( select ) => { + const { getCurrentPostLastRevisionId, getCurrentPostRevisionsCount } = + select( editorStore ); + return { + lastRevisionId: getCurrentPostLastRevisionId(), + revisionsCount: getCurrentPostRevisionsCount(), + }; + }, [] ); + if ( ! lastRevisionId || revisionsCount < 2 ) { return null; } @@ -25,11 +30,4 @@ export function PostLastRevisionCheck( { ); } -export default withSelect( ( select ) => { - const { getCurrentPostLastRevisionId, getCurrentPostRevisionsCount } = - select( editorStore ); - return { - lastRevisionId: getCurrentPostLastRevisionId(), - revisionsCount: getCurrentPostRevisionsCount(), - }; -} )( PostLastRevisionCheck ); +export default PostLastRevisionCheck; diff --git a/packages/editor/src/components/post-last-revision/index.js b/packages/editor/src/components/post-last-revision/index.js index 97434422b30f3d..17df8e8c38d3bf 100644 --- a/packages/editor/src/components/post-last-revision/index.js +++ b/packages/editor/src/components/post-last-revision/index.js @@ -3,7 +3,7 @@ */ import { sprintf, _n } from '@wordpress/i18n'; import { Button } from '@wordpress/components'; -import { withSelect } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; import { backup } from '@wordpress/icons'; import { addQueryArgs } from '@wordpress/url'; @@ -13,7 +13,16 @@ import { addQueryArgs } from '@wordpress/url'; import PostLastRevisionCheck from './check'; import { store as editorStore } from '../../store'; -function LastRevision( { lastRevisionId, revisionsCount } ) { +function LastRevision() { + const { lastRevisionId, revisionsCount } = useSelect( ( select ) => { + const { getCurrentPostLastRevisionId, getCurrentPostRevisionsCount } = + select( editorStore ); + return { + lastRevisionId: getCurrentPostLastRevisionId(), + revisionsCount: getCurrentPostRevisionsCount(), + }; + }, [] ); + return (