diff --git a/.github/workflows/end2end-test.yml b/.github/workflows/end2end-test.yml index 39748288c4d66..8a5e83d0fd442 100644 --- a/.github/workflows/end2end-test.yml +++ b/.github/workflows/end2end-test.yml @@ -23,8 +23,8 @@ jobs: strategy: fail-fast: false matrix: - part: [1, 2, 3, 4] - totalParts: [4] + part: [1, 2, 3] + totalParts: [3] steps: - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 @@ -67,8 +67,8 @@ jobs: strategy: fail-fast: false matrix: - part: [1, 2] - totalParts: [2] + part: [1, 2, 3, 4] + totalParts: [4] steps: - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 24cb7cfeefded..f59b472bdabdd 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -269,6 +269,15 @@ Add a link to a downloadable file. ([Source](https://github.com/WordPress/gutenb - **Supports:** align, anchor, color (background, gradients, link, ~~text~~) - **Attributes:** displayPreview, downloadButtonText, fileId, fileName, href, id, previewHeight, showDownloadButton, textLinkHref, textLinkTarget +## Footnotes + + ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/footnotes)) + +- **Name:** core/footnotes +- **Category:** text +- **Supports:** ~~html~~, ~~inserter~~, ~~multiple~~, ~~reusable~~ +- **Attributes:** + ## Classic Use the classic WordPress editor. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/freeform)) diff --git a/lib/blocks.php b/lib/blocks.php index fdbc555a2f944..8185567db1b80 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -24,6 +24,7 @@ function gutenberg_reregister_core_block_types() { 'comments', 'details', 'group', + 'footnotes', 'html', 'list', 'list-item', @@ -65,6 +66,7 @@ function gutenberg_reregister_core_block_types() { 'comments-pagination-previous.php' => 'core/comments-pagination-previous', 'comments-title.php' => 'core/comments-title', 'comments.php' => 'core/comments', + 'footnotes.php' => 'core/footnotes', 'file.php' => 'core/file', 'home-link.php' => 'core/home-link', 'image.php' => 'core/image', diff --git a/package-lock.json b/package-lock.json index 2b110762c27be..1187c5cd1295c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17343,7 +17343,8 @@ "memize": "^2.1.0", "micromodal": "^0.4.10", "preact": "^10.13.2", - "remove-accents": "^0.4.2" + "remove-accents": "^0.4.2", + "uuid": "^8.3.0" } }, "@wordpress/block-serialization-default-parser": { @@ -17508,6 +17509,7 @@ "requires": { "@babel/runtime": "^7.16.0", "@wordpress/api-fetch": "file:packages/api-fetch", + "@wordpress/block-editor": "file:packages/block-editor", "@wordpress/blocks": "file:packages/blocks", "@wordpress/compose": "file:packages/compose", "@wordpress/data": "file:packages/data", @@ -17516,6 +17518,7 @@ "@wordpress/html-entities": "file:packages/html-entities", "@wordpress/i18n": "file:packages/i18n", "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", + "@wordpress/private-apis": "file:packages/private-apis", "@wordpress/url": "file:packages/url", "change-case": "^4.1.2", "equivalent-key-map": "^0.2.2", diff --git a/packages/block-editor/src/components/block-removal-warning-modal/index.js b/packages/block-editor/src/components/block-removal-warning-modal/index.js new file mode 100644 index 0000000000000..2e16d2834d2ce --- /dev/null +++ b/packages/block-editor/src/components/block-removal-warning-modal/index.js @@ -0,0 +1,94 @@ +/** + * WordPress dependencies + */ +import { useEffect } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { + Modal, + Button, + __experimentalHStack as HStack, +} from '@wordpress/components'; +import { __, _n } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; +import { unlock } from '../../lock-unlock'; + +// In certain editing contexts, we'd like to prevent accidental removal of +// important blocks. For example, in the site editor, the Query Loop block is +// deemed important. In such cases, we'll ask the user for confirmation that +// they intended to remove such block(s). +// +// @see https://github.com/WordPress/gutenberg/pull/51145 +export const blockTypePromptMessages = { + 'core/query': __( 'Query Loop displays a list of posts or pages.' ), + 'core/post-content': __( + 'Post Content displays the content of a post or page.' + ), +}; + +export function BlockRemovalWarningModal() { + const { clientIds, selectPrevious, blockNamesForPrompt } = useSelect( + ( select ) => + unlock( select( blockEditorStore ) ).getRemovalPromptData() + ); + + const { + clearRemovalPrompt, + toggleRemovalPromptSupport, + privateRemoveBlocks, + } = unlock( useDispatch( blockEditorStore ) ); + + // Signalling the removal prompt is in place. + useEffect( () => { + toggleRemovalPromptSupport( true ); + return () => { + toggleRemovalPromptSupport( false ); + }; + }, [ toggleRemovalPromptSupport ] ); + + if ( ! blockNamesForPrompt ) { + return; + } + + const onConfirmRemoval = () => { + privateRemoveBlocks( clientIds, selectPrevious, /* force */ true ); + clearRemovalPrompt(); + }; + + return ( + + { blockNamesForPrompt.length === 1 ? ( +

{ blockTypePromptMessages[ blockNamesForPrompt[ 0 ] ] }

+ ) : ( + + ) } +

+ { _n( + 'Removing this block is not advised.', + 'Removing these blocks is not advised.', + blockNamesForPrompt.length + ) } +

+ + + + +
+ ); +} diff --git a/packages/block-editor/src/components/copy-handler/index.js b/packages/block-editor/src/components/copy-handler/index.js index 3881ff06f2bf3..3acaac367bd64 100644 --- a/packages/block-editor/src/components/copy-handler/index.js +++ b/packages/block-editor/src/components/copy-handler/index.js @@ -100,6 +100,11 @@ export function useClipboardHandler() { return useRefEffect( ( node ) => { function handler( event ) { + if ( event.defaultPrevented ) { + // This was likely already handled in rich-text/use-paste-handler.js. + return; + } + const selectedBlockClientIds = getSelectedBlockClientIds(); if ( selectedBlockClientIds.length === 0 ) { @@ -127,7 +132,6 @@ export function useClipboardHandler() { return; } - const eventDefaultPrevented = event.defaultPrevented; event.preventDefault(); const isSelectionMergeable = __unstableIsSelectionMergeable(); @@ -197,10 +201,6 @@ export function useClipboardHandler() { __unstableDeleteSelection(); } } else if ( event.type === 'paste' ) { - if ( eventDefaultPrevented ) { - // This was likely already handled in rich-text/use-paste-handler.js. - return; - } const { __experimentalCanUserUseUnfilteredHTML: canUserUseUnfilteredHTML, diff --git a/packages/block-editor/src/components/inserter/library.js b/packages/block-editor/src/components/inserter/library.js index 23dfdb7fd7dc6..3a814638c2f48 100644 --- a/packages/block-editor/src/components/inserter/library.js +++ b/packages/block-editor/src/components/inserter/library.js @@ -36,7 +36,8 @@ function InserterLibrary( return { destinationRootClientId: _rootClientId, prioritizePatterns: - getSettings().__experimentalPreferPatternsOnRoot, + getSettings().__experimentalPreferPatternsOnRoot && + ! _rootClientId, }; }, [ clientId, rootClientId ] diff --git a/packages/block-editor/src/components/list-view/branch.js b/packages/block-editor/src/components/list-view/branch.js index 785e6a538f715..d3b555c055afd 100644 --- a/packages/block-editor/src/components/list-view/branch.js +++ b/packages/block-editor/src/components/list-view/branch.js @@ -91,6 +91,7 @@ function ListViewBranch( props ) { selectedClientIds, level = 1, path = '', + isBranchDragged = false, isBranchSelected = false, listPosition = 0, fixedListWindow, @@ -167,7 +168,8 @@ function ListViewBranch( props ) { ); const isSelectedBranch = isBranchSelected || ( isSelected && hasNestedBlocks ); - const showBlock = isDragged || blockInView || isSelected; + const showBlock = + isDragged || blockInView || isSelected || isBranchDragged; return ( { showBlock && ( @@ -176,7 +178,7 @@ function ListViewBranch( props ) { selectBlock={ selectBlock } isSelected={ isSelected } isBranchSelected={ isSelectedBranch } - isDragged={ isDragged } + isDragged={ isDragged || isBranchDragged } level={ level } position={ position } rowCount={ rowCount } @@ -194,7 +196,7 @@ function ListViewBranch( props ) { ) } - { hasNestedBlocks && shouldExpand && ! isDragged && ( + { hasNestedBlocks && shouldExpand && ( { const clientId = blockElement.dataset.block; const isExpanded = blockElement.dataset.expanded === 'true'; + const isDraggedBlock = + blockElement.classList.contains( 'is-dragging' ); // Get nesting level from `aria-level` attribute because Firefox does not support `element.ariaLevel`. const nestingLevel = parseInt( @@ -449,9 +451,7 @@ export default function useListViewDropZone( { dropZoneElement } ) { blockIndex: getBlockIndex( clientId ), element: blockElement, nestingLevel: nestingLevel || undefined, - isDraggedBlock: isBlockDrag - ? draggedBlockClientIds.includes( clientId ) - : false, + isDraggedBlock: isBlockDrag ? isDraggedBlock : false, innerBlockCount: getBlockCount( clientId ), canInsertDraggedBlocksAsSibling: isBlockDrag ? canInsertBlocks( diff --git a/packages/block-editor/src/components/rich-text/content.js b/packages/block-editor/src/components/rich-text/content.js new file mode 100644 index 0000000000000..dfd206a1ddb7e --- /dev/null +++ b/packages/block-editor/src/components/rich-text/content.js @@ -0,0 +1,85 @@ +/** + * WordPress dependencies + */ +import { RawHTML } from '@wordpress/element'; +import { + children as childrenSource, + getSaveElement, + __unstableGetBlockProps as getBlockProps, +} from '@wordpress/blocks'; +import deprecated from '@wordpress/deprecated'; + +/** + * Internal dependencies + */ +import { getMultilineTag } from './utils'; + +export const Content = ( { value, tagName: Tag, multiline, ...props } ) => { + // Handle deprecated `children` and `node` sources. + if ( Array.isArray( value ) ) { + deprecated( 'wp.blockEditor.RichText value prop as children type', { + since: '6.1', + version: '6.3', + alternative: 'value prop as string', + link: 'https://developer.wordpress.org/block-editor/how-to-guides/block-tutorial/introducing-attributes-and-editable-fields/', + } ); + + value = childrenSource.toHTML( value ); + } + + const MultilineTag = getMultilineTag( multiline ); + + if ( ! value && MultilineTag ) { + value = `<${ MultilineTag }>`; + } + + const content = { value }; + + if ( Tag ) { + const { format, ...restProps } = props; + return { content }; + } + + return content; +}; + +Content.__unstableIsRichTextContent = {}; + +function findContent( blocks, richTextValues = [] ) { + if ( ! Array.isArray( blocks ) ) { + blocks = [ blocks ]; + } + + for ( const block of blocks ) { + if ( + block?.type?.__unstableIsRichTextContent === + Content.__unstableIsRichTextContent + ) { + richTextValues.push( block.props.value ); + continue; + } + + if ( block?.props?.children ) { + findContent( block.props.children, richTextValues ); + } + } + + return richTextValues; +} + +function _getSaveElement( { name, attributes, innerBlocks } ) { + return getSaveElement( + name, + attributes, + innerBlocks.map( _getSaveElement ) + ); +} + +export function getRichTextValues( blocks = [] ) { + getBlockProps.skipFilters = true; + const values = findContent( + ( Array.isArray( blocks ) ? blocks : [ blocks ] ).map( _getSaveElement ) + ); + getBlockProps.skipFilters = false; + return values; +} diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index 0d61fc87f7fc7..50d05e7eb4dde 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -7,7 +7,6 @@ import classnames from 'classnames'; * WordPress dependencies */ import { - RawHTML, useRef, useCallback, forwardRef, @@ -46,6 +45,7 @@ import { useInsertReplacementText } from './use-insert-replacement-text'; import { useFirefoxCompat } from './use-firefox-compat'; import FormatEdit from './format-edit'; import { getMultilineTag, getAllowedFormats } from './utils'; +import { Content } from './content'; export const keyboardShortcutContext = createContext(); export const inputEventContext = createContext(); @@ -419,40 +419,7 @@ function RichTextWrapper( const ForwardedRichTextContainer = forwardRef( RichTextWrapper ); -ForwardedRichTextContainer.Content = ( { - value, - tagName: Tag, - multiline, - ...props -} ) => { - // Handle deprecated `children` and `node` sources. - if ( Array.isArray( value ) ) { - deprecated( 'wp.blockEditor.RichText value prop as children type', { - since: '6.1', - version: '6.3', - alternative: 'value prop as string', - link: 'https://developer.wordpress.org/block-editor/how-to-guides/block-tutorial/introducing-attributes-and-editable-fields/', - } ); - - value = childrenSource.toHTML( value ); - } - - const MultilineTag = getMultilineTag( multiline ); - - if ( ! value && MultilineTag ) { - value = `<${ MultilineTag }>`; - } - - const content = { value }; - - if ( Tag ) { - const { format, ...restProps } = props; - return { content }; - } - - return content; -}; - +ForwardedRichTextContainer.Content = Content; ForwardedRichTextContainer.isEmpty = ( value ) => { return ! value || value.length === 0; }; diff --git a/packages/block-editor/src/components/rich-text/index.native.js b/packages/block-editor/src/components/rich-text/index.native.js index 82e7a96ec8733..b0c82848db687 100644 --- a/packages/block-editor/src/components/rich-text/index.native.js +++ b/packages/block-editor/src/components/rich-text/index.native.js @@ -6,13 +6,7 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { - RawHTML, - Platform, - useRef, - useCallback, - forwardRef, -} from '@wordpress/element'; +import { Platform, useRef, useCallback, forwardRef } from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; import { pasteHandler, @@ -55,6 +49,7 @@ import { createLinkInParagraph, } from './utils'; import EmbedHandlerPicker from './embed-handler-picker'; +import { Content } from './content'; const classes = 'block-editor-rich-text__editable'; @@ -707,32 +702,7 @@ function RichTextWrapper( const ForwardedRichTextContainer = forwardRef( RichTextWrapper ); -ForwardedRichTextContainer.Content = ( { - value, - tagName: Tag, - multiline, - ...props -} ) => { - // Handle deprecated `children` and `node` sources. - if ( Array.isArray( value ) ) { - value = childrenSource.toHTML( value ); - } - - const MultilineTag = getMultilineTag( multiline ); - - if ( ! value && MultilineTag ) { - value = `<${ MultilineTag }>`; - } - - const content = { value }; - - if ( Tag ) { - const { format, ...restProps } = props; - return { content }; - } - - return content; -}; +ForwardedRichTextContainer.Content = Content; ForwardedRichTextContainer.isEmpty = ( value ) => { return ! value || value.length === 0; diff --git a/packages/block-editor/src/components/writing-flow/use-drag-selection.js b/packages/block-editor/src/components/writing-flow/use-drag-selection.js index 87d5e93f90994..55fb078c765d3 100644 --- a/packages/block-editor/src/components/writing-flow/use-drag-selection.js +++ b/packages/block-editor/src/components/writing-flow/use-drag-selection.js @@ -88,7 +88,7 @@ export default function useDragSelection() { // child elements of the content editable wrapper are editable // and return true for this property. We only want to start // multi selecting when the mouse leaves the wrapper. - if ( ! target.getAttribute( 'contenteditable' ) ) { + if ( target.getAttribute( 'contenteditable' ) !== 'true' ) { return; } diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.js b/packages/block-editor/src/components/writing-flow/use-tab-nav.js index 214072ab28e93..b22e57a51e32a 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.js +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.js @@ -116,17 +116,53 @@ export default function useTabNav() { return; } + // We want to constrain the tabbing to the block and its child blocks. + // If the preceding form element is within a different block, + // such as two sibling image blocks in the placeholder state, + // we want shift + tab from the first form element to move to the image + // block toolbar and not the previous image block's form element. + // TODO: Should this become a utility function? + /** + * Determine whether an element is part of or is the selected block. + * + * @param {Object} selectedBlockElement + * @param {Object} element + * @return {boolean} Whether the element is part of or is the selected block. + */ + const isElementPartOfSelectedBlock = ( + selectedBlockElement, + element + ) => { + // Check if the element is or is within the selected block by finding the + // closest element with a data-block attribute and seeing if + // it matches our current selected block ID + const elementBlockId = element + .closest( '[data-block]' ) + ?.getAttribute( 'data-block' ); + const isElementSameBlock = + elementBlockId === getSelectedBlockClientId(); + + // Check if the element is a child of the selected block. This could be a + // child block in a group or column block, etc. + const isElementChildOfBlock = + selectedBlockElement.contains( element ); + + return isElementSameBlock || isElementChildOfBlock; + }; + + const nextTabbable = focus.tabbable[ direction ]( event.target ); // Allow tabbing from the block wrapper to a form element, - // and between form elements rendered in a block, + // and between form elements rendered in a block and its child blocks, // such as inside a placeholder. Form elements are generally // meant to be UI rather than part of the content. Ideally // these are not rendered in the content and perhaps in the // future they can be rendered in an iframe or shadow DOM. if ( - ( isFormElement( event.target ) || - event.target.getAttribute( 'data-block' ) === - getSelectedBlockClientId() ) && - isFormElement( focus.tabbable[ direction ]( event.target ) ) + isFormElement( nextTabbable ) && + isElementPartOfSelectedBlock( + event.target.closest( '[data-block]' ), + nextTabbable + ) ) { return; } diff --git a/packages/block-editor/src/private-apis.js b/packages/block-editor/src/private-apis.js index b9ec71fc1a786..9cef7e0cda8e0 100644 --- a/packages/block-editor/src/private-apis.js +++ b/packages/block-editor/src/private-apis.js @@ -4,6 +4,7 @@ import * as globalStyles from './components/global-styles'; import { ExperimentalBlockEditorProvider } from './components/provider'; import { lock } from './lock-unlock'; +import { getRichTextValues } from './components/rich-text/content'; import ResizableBoxPopover from './components/resizable-box-popover'; import { ComposedPrivateInserter as PrivateInserter } from './components/inserter'; import { PrivateListView } from './components/list-view'; @@ -13,6 +14,7 @@ import { cleanEmptyObject } from './hooks/utils'; import { useBlockEditingMode } from './components/block-editing-mode'; import BlockQuickNavigation from './components/block-quick-navigation'; import { LayoutStyle } from './components/block-list/layout'; +import { BlockRemovalWarningModal } from './components/block-removal-warning-modal'; import { useLayoutClasses, useLayoutStyles } from './hooks'; /** @@ -22,6 +24,7 @@ export const privateApis = {}; lock( privateApis, { ...globalStyles, ExperimentalBlockEditorProvider, + getRichTextValues, PrivateInserter, PrivateListView, ResizableBoxPopover, @@ -31,6 +34,7 @@ lock( privateApis, { useBlockEditingMode, BlockQuickNavigation, LayoutStyle, + BlockRemovalWarningModal, useLayoutClasses, useLayoutStyles, } ); diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index 27ff57e74919c..f28fb604c1f76 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -25,40 +25,17 @@ import { retrieveSelectedAttribute, START_OF_SELECTED_AREA, } from '../utils/selection'; -import { __experimentalUpdateSettings } from './private-actions'; +import { + __experimentalUpdateSettings, + ensureDefaultBlock, + privateRemoveBlocks, +} from './private-actions'; /** @typedef {import('../components/use-on-block-drop/types').WPDropOperation} WPDropOperation */ const castArray = ( maybeArray ) => Array.isArray( maybeArray ) ? maybeArray : [ maybeArray ]; -/** - * Action which will insert a default block insert action if there - * are no other blocks at the root of the editor. This action should be used - * in actions which may result in no blocks remaining in the editor (removal, - * replacement, etc). - */ -const ensureDefaultBlock = - () => - ( { select, dispatch } ) => { - // 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. - const count = select.getBlockCount(); - if ( count > 0 ) { - return; - } - - // If there's an custom appender, don't insert default block. - // We have to remember to manually move the focus elsewhere to - // prevent it from being lost though. - const { __unstableHasCustomAppender } = select.getSettings(); - if ( __unstableHasCustomAppender ) { - return; - } - - dispatch.insertDefaultBlock(); - }; - /** * Action that resets blocks state to the specified array of blocks, taking precedence * over any other content reflected as an edit in state. @@ -1195,34 +1172,8 @@ export const mergeBlocks = * should be selected * when a block is removed. */ -export const removeBlocks = - ( clientIds, selectPrevious = true ) => - ( { select, dispatch } ) => { - if ( ! clientIds || ! clientIds.length ) { - return; - } - - clientIds = castArray( clientIds ); - const rootClientId = select.getBlockRootClientId( clientIds[ 0 ] ); - const canRemoveBlocks = select.canRemoveBlocks( - clientIds, - rootClientId - ); - - if ( ! canRemoveBlocks ) { - return; - } - - if ( selectPrevious ) { - dispatch.selectPreviousBlock( clientIds[ 0 ], selectPrevious ); - } - - dispatch( { type: 'REMOVE_BLOCKS', clientIds } ); - - // 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. - dispatch( ensureDefaultBlock() ); - }; +export const removeBlocks = ( clientIds, selectPrevious = true ) => + privateRemoveBlocks( clientIds, selectPrevious ); /** * Returns an action object used in signalling that the block with the diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js index 0a3484154e5a7..74c83d6018969 100644 --- a/packages/block-editor/src/store/private-actions.js +++ b/packages/block-editor/src/store/private-actions.js @@ -3,6 +3,14 @@ */ import { Platform } from '@wordpress/element'; +/** + * Internal dependencies + */ +import { blockTypePromptMessages } from '../components/block-removal-warning-modal'; + +const castArray = ( maybeArray ) => + Array.isArray( maybeArray ) ? maybeArray : [ maybeArray ]; + /** * A list of private/experimental block editor settings that * should not become a part of the WordPress public API. @@ -105,3 +113,188 @@ export function unsetBlockEditingMode( clientId = '' ) { clientId, }; } + +/** + * Yields action objects used in signalling that the blocks corresponding to + * the set of specified client IDs are to be removed. + * + * Compared to `removeBlocks`, this private interface exposes an additional + * parameter; see `forceRemove`. + * + * @param {string|string[]} clientIds Client IDs of blocks to remove. + * @param {boolean} selectPrevious True if the previous block + * or the immediate parent + * (if no previous block exists) + * should be selected + * when a block is removed. + * @param {boolean} forceRemove Whether to force the operation, + * bypassing any checks for certain + * block types. + */ +export const privateRemoveBlocks = + ( clientIds, selectPrevious = true, forceRemove = false ) => + ( { select, dispatch } ) => { + if ( ! clientIds || ! clientIds.length ) { + return; + } + + clientIds = castArray( clientIds ); + const rootClientId = select.getBlockRootClientId( clientIds[ 0 ] ); + const canRemoveBlocks = select.canRemoveBlocks( + clientIds, + rootClientId + ); + + if ( ! canRemoveBlocks ) { + return; + } + + // In certain editing contexts, we'd like to prevent accidental removal + // of important blocks. For example, in the site editor, the Query Loop + // block is deemed important. In such cases, we'll ask the user for + // confirmation that they intended to remove such block(s). However, + // the editor instance is responsible for presenting those confirmation + // prompts to the user. Any instance opting into removal prompts must + // register using `toggleRemovalPromptSupport()`. + // + // @see https://github.com/WordPress/gutenberg/pull/51145 + if ( + ! forceRemove && + // FIXME: Without this existence check, the unit tests for + // `__experimentalDeleteReusableBlock` in + // `packages/reusable-blocks/src/store/test/actions.js` fail due to + // the fact that the `registry` object passed to the thunk actions + // doesn't include this private action. This needs to be + // investigated to understand whether it's a real smell or if it's + // because not all store code has been updated to accommodate + // private selectors. + select.isRemovalPromptSupported && + select.isRemovalPromptSupported() + ) { + const blockNamesForPrompt = new Set(); + + // Given a list of client IDs of blocks that the user intended to + // remove, perform a tree search (BFS) to find all block names + // corresponding to "important" blocks, i.e. blocks that require a + // removal prompt. + // + // @see blockTypePromptMessages + const queue = [ ...clientIds ]; + while ( queue.length ) { + const clientId = queue.shift(); + const blockName = select.getBlockName( clientId ); + if ( blockTypePromptMessages[ blockName ] ) { + blockNamesForPrompt.add( blockName ); + } + const innerBlocks = select.getBlockOrder( clientId ); + queue.push( ...innerBlocks ); + } + + // If any such blocks were found, trigger the removal prompt and + // skip any other steps (thus postponing actual removal). + if ( blockNamesForPrompt.size ) { + dispatch( + displayRemovalPrompt( + clientIds, + selectPrevious, + Array.from( blockNamesForPrompt ) + ) + ); + return; + } + } + + if ( selectPrevious ) { + dispatch.selectPreviousBlock( clientIds[ 0 ], selectPrevious ); + } + + dispatch( { type: 'REMOVE_BLOCKS', clientIds } ); + + // 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. + dispatch( ensureDefaultBlock() ); + }; + +/** + * Action which will insert a default block insert action if there + * are no other blocks at the root of the editor. This action should be used + * in actions which may result in no blocks remaining in the editor (removal, + * replacement, etc). + */ +export const ensureDefaultBlock = + () => + ( { select, dispatch } ) => { + // 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. + const count = select.getBlockCount(); + if ( count > 0 ) { + return; + } + + // If there's an custom appender, don't insert default block. + // We have to remember to manually move the focus elsewhere to + // prevent it from being lost though. + const { __unstableHasCustomAppender } = select.getSettings(); + if ( __unstableHasCustomAppender ) { + return; + } + + dispatch.insertDefaultBlock(); + }; + +/** + * Returns an action object used in signalling that a block removal prompt must + * be displayed. + * + * Contrast with `toggleRemovalPromptSupport`. + * + * @param {string|string[]} clientIds Client IDs of blocks to remove. + * @param {boolean} selectPrevious True if the previous block + * or the immediate parent + * (if no previous block exists) + * should be selected + * when a block is removed. + * @param {string[]} blockNamesForPrompt Names of blocks requiring user + * @return {Object} Action object. + */ +export function displayRemovalPrompt( + clientIds, + selectPrevious, + blockNamesForPrompt +) { + return { + type: 'DISPLAY_REMOVAL_PROMPT', + clientIds, + selectPrevious, + blockNamesForPrompt, + }; +} + +/** + * Returns an action object used in signalling that a block removal prompt must + * be cleared, either be cause the user has confirmed or canceled the request + * for removal. + * + * @return {Object} Action object. + */ +export function clearRemovalPrompt() { + return { + type: 'CLEAR_REMOVAL_PROMPT', + }; +} + +/** + * Returns an action object used in signalling that a removal prompt display + * mechanism is available or unavailable in the current editor. + * + * Contrast with `displayRemovalPrompt`. + * + * @param {boolean} status Whether a prompt display mechanism exists. + * @return {Object} Action object. + */ +export function toggleRemovalPromptSupport( status = true ) { + return { + type: 'TOGGLE_REMOVAL_PROMPT_SUPPORT', + status, + }; +} diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index a1ae32d560239..454f3f32ba8f7 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -185,3 +185,26 @@ export const getEnabledBlockParents = createSelector( state.blockListSettings, ] ); + +/** + * Selector that returns the data needed to display a prompt when certain + * blocks are removed, or `false` if no such prompt is requested. + * + * @param {Object} state Global application state. + * + * @return {Object|false} Data for removal prompt display, if any. + */ +export function getRemovalPromptData( state ) { + return state.removalPromptData; +} + +/** + * Returns true if removal prompt exists, or false otherwise. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether removal prompt exists. + */ +export function isRemovalPromptSupported( state ) { + return state.isRemovalPromptSupported; +} diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index b851c0293c8f1..d23f5f3058978 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -1469,6 +1469,48 @@ export function isSelectionEnabled( state = true, action ) { return state; } +/** + * Reducer returning the data needed to display a prompt when certain blocks + * are removed, or `false` if no such prompt is requested. + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object|false} Data for removal prompt display, if any. + */ +function removalPromptData( state = false, action ) { + switch ( action.type ) { + case 'DISPLAY_REMOVAL_PROMPT': + const { clientIds, selectPrevious, blockNamesForPrompt } = action; + return { + clientIds, + selectPrevious, + blockNamesForPrompt, + }; + case 'CLEAR_REMOVAL_PROMPT': + return false; + } + + return state; +} + +/** + * Reducer prompt availability state. + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +function isRemovalPromptSupported( state = false, action ) { + switch ( action.type ) { + case 'TOGGLE_REMOVAL_PROMPT_SUPPORT': + return action.status; + } + + return state; +} + /** * Reducer returning the initial block selection. * @@ -1881,6 +1923,8 @@ const combinedReducers = combineReducers( { temporarilyEditingAsBlocks, blockVisibility, blockEditingModes, + removalPromptData, + isRemovalPromptSupported, } ); function withAutomaticChangeReset( reducer ) { diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 6a62f50c2a2f7..05d00f65b1fd3 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -2837,10 +2837,11 @@ export function __unstableGetTemporarilyEditingAsBlocks( state ) { } export function __unstableHasActiveBlockOverlayActive( state, clientId ) { - // Prevent overlay on disabled blocks. It's redundant since disabled blocks - // can't be selected, and prevents non-disabled nested blocks from being - // selected. - if ( getBlockEditingMode( state, clientId ) === 'disabled' ) { + // Prevent overlay on blocks with a non-default editing mode. If the mdoe is + // 'disabled' then the overlay is redundant since the block can't be + // selected. If the mode is 'contentOnly' then the overlay is redundant + // since there will be no controls to interact with once selected. + if ( getBlockEditingMode( state, clientId ) !== 'default' ) { return false; } diff --git a/packages/block-library/package.json b/packages/block-library/package.json index 91ccb7552f6b4..8d145f51d4a76 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -73,7 +73,8 @@ "memize": "^2.1.0", "micromodal": "^0.4.10", "preact": "^10.13.2", - "remove-accents": "^0.4.2" + "remove-accents": "^0.4.2", + "uuid": "^8.3.0" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/block-library/src/footnotes/block.json b/packages/block-library/src/footnotes/block.json new file mode 100644 index 0000000000000..0ab992009d123 --- /dev/null +++ b/packages/block-library/src/footnotes/block.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "core/footnotes", + "title": "Footnotes", + "category": "text", + "description": "", + "keywords": [ "references" ], + "textdomain": "default", + "usesContext": [ "postId", "postType" ], + "supports": { + "html": false, + "multiple": false, + "inserter": false, + "reusable": false + }, + "style": "wp-block-footnotes" +} diff --git a/packages/block-library/src/footnotes/edit.js b/packages/block-library/src/footnotes/edit.js new file mode 100644 index 0000000000000..e90a7f82be94a --- /dev/null +++ b/packages/block-library/src/footnotes/edit.js @@ -0,0 +1,52 @@ +/** + * WordPress dependencies + */ +import { RichText, useBlockProps } from '@wordpress/block-editor'; +import { useEntityProp } from '@wordpress/core-data'; + +export default function FootnotesEdit( { context: { postType, postId } } ) { + const [ meta, updateMeta ] = useEntityProp( + 'postType', + postType, + 'meta', + postId + ); + const footnotes = meta?.footnotes ? JSON.parse( meta.footnotes ) : []; + return ( +
    + { footnotes.map( ( { id, content } ) => ( +
  1. + { + if ( ! event.target.textContent.trim() ) { + event.target.scrollIntoView(); + } + } } + onChange={ ( nextFootnote ) => { + updateMeta( { + ...meta, + footnotes: JSON.stringify( + footnotes.map( ( footnote ) => { + return footnote.id === id + ? { + content: nextFootnote, + id, + } + : footnote; + } ) + ), + } ); + } } + />{ ' ' } + ↩︎ +
  2. + ) ) } +
+ ); +} diff --git a/packages/block-library/src/footnotes/format.js b/packages/block-library/src/footnotes/format.js new file mode 100644 index 0000000000000..7c1b190fc42af --- /dev/null +++ b/packages/block-library/src/footnotes/format.js @@ -0,0 +1,112 @@ +/** + * External dependencies + */ +import { v4 as createId } from 'uuid'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { formatListNumbered as icon } from '@wordpress/icons'; +import { insertObject } from '@wordpress/rich-text'; +import { + RichTextToolbarButton, + store as blockEditorStore, +} from '@wordpress/block-editor'; +import { useSelect, useDispatch, useRegistry } from '@wordpress/data'; +import { createBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { name } from './block.json'; + +export const formatName = 'core/footnote'; +export const format = { + title: __( 'Footnote' ), + tagName: 'a', + className: 'fn', + attributes: { + id: 'id', + href: 'href', + 'data-fn': 'data-fn', + }, + contentEditable: false, + edit: function Edit( { value, onChange, isObjectActive } ) { + const registry = useRegistry(); + const { + getSelectedBlockClientId, + getBlockRootClientId, + getBlockName, + getBlocks, + } = useSelect( blockEditorStore ); + const { selectionChange, insertBlock } = + useDispatch( blockEditorStore ); + function onClick() { + registry.batch( () => { + const id = createId(); + const newValue = insertObject( + value, + { + type: formatName, + attributes: { + href: '#' + id, + id: `${ id }-link`, + 'data-fn': id, + }, + innerHTML: '*', + }, + value.end, + value.end + ); + newValue.start = newValue.end - 1; + + onChange( newValue ); + + // BFS search to find the first footnote block. + let fnBlock = null; + { + const queue = [ ...getBlocks() ]; + while ( queue.length ) { + const block = queue.shift(); + if ( block.name === name ) { + fnBlock = block; + break; + } + queue.push( ...block.innerBlocks ); + } + } + + // Maybe this should all also be moved to the entity provider. + // When there is no footnotes block in the post, create one and + // insert it at the bottom. + if ( ! fnBlock ) { + const clientId = getSelectedBlockClientId(); + let rootClientId = getBlockRootClientId( clientId ); + + while ( + rootClientId && + getBlockName( rootClientId ) !== 'core/post-content' + ) { + rootClientId = getBlockRootClientId( rootClientId ); + } + + fnBlock = createBlock( name ); + + insertBlock( fnBlock, undefined, rootClientId ); + } + + selectionChange( fnBlock.clientId, id, 0, 0 ); + } ); + } + + return ( + + ); + }, +}; diff --git a/packages/block-library/src/footnotes/index.js b/packages/block-library/src/footnotes/index.js new file mode 100644 index 0000000000000..c0f3d60ada543 --- /dev/null +++ b/packages/block-library/src/footnotes/index.js @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ +import { formatListNumbered as icon } from '@wordpress/icons'; +import { registerFormatType } from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import initBlock from '../utils/init-block'; +import edit from './edit'; +import metadata from './block.json'; +import { formatName, format } from './format'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + icon, + edit, +}; + +// Would be good to remove the format and HoR if the block is unregistered. +registerFormatType( formatName, format ); + +export const init = () => { + initBlock( { name, metadata, settings } ); +}; diff --git a/packages/block-library/src/footnotes/index.php b/packages/block-library/src/footnotes/index.php new file mode 100644 index 0000000000000..b7be0542d7a0f --- /dev/null +++ b/packages/block-library/src/footnotes/index.php @@ -0,0 +1,78 @@ +context['postId'] ) ) { + return ''; + } + + if ( post_password_required( $block->context['postId'] ) ) { + return; + } + + $footnotes = get_post_meta( $block->context['postId'], 'footnotes', true ); + + if ( ! $footnotes ) { + return; + } + + $footnotes = json_decode( $footnotes, true ); + + if ( count( $footnotes ) === 0 ) { + return ''; + } + + $wrapper_attributes = get_block_wrapper_attributes(); + + $block_content = ''; + + foreach ( $footnotes as $footnote ) { + $block_content .= sprintf( + '
  • %2$s ↩︎
  • ', + $footnote['id'], + $footnote['content'] + ); + } + + return sprintf( + '
      %2$s
    ', + $wrapper_attributes, + $block_content + ); +} + +/** + * Registers the `core/footnotes` block on the server. + */ +function register_block_core_footnotes() { + register_post_meta( + 'post', + 'footnotes', + array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + ) + ); + register_block_type_from_metadata( + __DIR__ . '/footnotes', + array( + 'render_callback' => 'render_block_core_footnotes', + ) + ); +} +add_action( 'init', 'register_block_core_footnotes' ); diff --git a/packages/block-library/src/footnotes/style.scss b/packages/block-library/src/footnotes/style.scss new file mode 100644 index 0000000000000..b5540b70e6f0f --- /dev/null +++ b/packages/block-library/src/footnotes/style.scss @@ -0,0 +1,18 @@ +.editor-styles-wrapper, +.entry-content { + counter-reset: footnotes; +} + +.fn { + vertical-align: super; + font-size: smaller; + counter-increment: footnotes; + display: inline-block; + text-indent: -9999999px; +} + +.fn::after { + content: "[" counter(footnotes) "]"; + text-indent: 0; + float: left; +} diff --git a/packages/block-library/src/gallery/edit.js b/packages/block-library/src/gallery/edit.js index 8f4b785e57741..acf8d54efb127 100644 --- a/packages/block-library/src/gallery/edit.js +++ b/packages/block-library/src/gallery/edit.js @@ -144,7 +144,10 @@ function GalleryEdit( props ) { const innerBlockImages = useSelect( ( select ) => { - return select( blockEditorStore ).getBlock( clientId )?.innerBlocks; + const innerBlocks = + select( blockEditorStore ).getBlock( clientId )?.innerBlocks ?? + []; + return innerBlocks; }, [ clientId ] ); diff --git a/packages/block-library/src/gallery/test/__snapshots__/index.native.js.snap b/packages/block-library/src/gallery/test/__snapshots__/index.native.js.snap index ee156d806d4ba..b79bd7c3877c7 100644 --- a/packages/block-library/src/gallery/test/__snapshots__/index.native.js.snap +++ b/packages/block-library/src/gallery/test/__snapshots__/index.native.js.snap @@ -110,6 +110,12 @@ exports[`Gallery block rearranges gallery items 1`] = ` " `; +exports[`Gallery block renders gallery block placeholder correctly if the block doesn't have inner blocks 1`] = ` +" + +" +`; + exports[`Gallery block sets caption to gallery 1`] = ` "