UNPKG

@gechiui/block-editor

Version:
1,635 lines (1,492 loc) 70.4 kB
/** * External dependencies */ import { castArray, flatMap, first, isArray, isBoolean, last, map, reduce, some, find, filter, mapKeys, orderBy, } from 'lodash'; import createSelector from 'rememo'; /** * GeChiUI dependencies */ import { getBlockType, getBlockTypes, getBlockVariations, hasBlockSupport, getPossibleBlockTransformations, parse, } from '@gechiui/blocks'; import { Platform } from '@gechiui/element'; import { applyFilters } from '@gechiui/hooks'; import { symbol } from '@gechiui/icons'; /** * A block selection object. * * @typedef {Object} GCBlockSelection * * @property {string} clientId A block client ID. * @property {string} attributeKey A block attribute key. * @property {number} offset An attribute value offset, based on the rich * text value. See `gc.richText.create`. */ // Module constants const MILLISECONDS_PER_HOUR = 3600 * 1000; const MILLISECONDS_PER_DAY = 24 * 3600 * 1000; const MILLISECONDS_PER_WEEK = 7 * 24 * 3600 * 1000; /** * Shared reference to an empty array for cases where it is important to avoid * returning a new array reference on every invocation, as in a connected or * other pure component which performs `shouldComponentUpdate` check on props. * This should be used as a last resort, since the normalized data should be * maintained by the reducer result in state. * * @type {Array} */ const EMPTY_ARRAY = []; /** * Returns a block's name given its client ID, or null if no block exists with * the client ID. * * @param {Object} state Editor state. * @param {string} clientId Block client ID. * * @return {string} Block name. */ export function getBlockName( state, clientId ) { const block = state.blocks.byClientId[ clientId ]; const socialLinkName = 'core/social-link'; if ( Platform.OS !== 'web' && block?.name === socialLinkName ) { const attributes = state.blocks.attributes[ clientId ]; const { service } = attributes; return service ? `${ socialLinkName }-${ service }` : socialLinkName; } return block ? block.name : null; } /** * Returns whether a block is valid or not. * * @param {Object} state Editor state. * @param {string} clientId Block client ID. * * @return {boolean} Is Valid. */ export function isBlockValid( state, clientId ) { const block = state.blocks.byClientId[ clientId ]; return !! block && block.isValid; } /** * Returns a block's attributes given its client ID, or null if no block exists with * the client ID. * * @param {Object} state Editor state. * @param {string} clientId Block client ID. * * @return {Object?} Block attributes. */ export function getBlockAttributes( state, clientId ) { const block = state.blocks.byClientId[ clientId ]; if ( ! block ) { return null; } return state.blocks.attributes[ clientId ]; } /** * Returns a block given its client ID. This is a parsed copy of the block, * containing its `blockName`, `clientId`, and current `attributes` state. This * is not the block's registration settings, which must be retrieved from the * blocks module registration store. * * getBlock recurses through its inner blocks until all its children blocks have * been retrieved. Note that getBlock will not return the child inner blocks of * an inner block controller. This is because an inner block controller syncs * itself with its own entity, and should therefore not be included with the * blocks of a different entity. For example, say you call `getBlocks( TP )` to * get the blocks of a template part. If another template part is a child of TP, * then the nested template part's child blocks will not be returned. This way, * the template block itself is considered part of the parent, but the children * are not. * * @param {Object} state Editor state. * @param {string} clientId Block client ID. * * @return {Object} Parsed block object. */ export function getBlock( state, clientId ) { const block = state.blocks.byClientId[ clientId ]; if ( ! block ) { return null; } return state.blocks.tree[ clientId ]; } export const __unstableGetBlockWithoutInnerBlocks = createSelector( ( state, clientId ) => { const block = state.blocks.byClientId[ clientId ]; if ( ! block ) { return null; } return { ...block, attributes: getBlockAttributes( state, clientId ), }; }, ( state, clientId ) => [ state.blocks.byClientId[ clientId ], state.blocks.attributes[ clientId ], ] ); /** * Returns all block objects for the current post being edited as an array in * the order they appear in the post. Note that this will exclude child blocks * of nested inner block controllers. * * @param {Object} state Editor state. * @param {?string} rootClientId Optional root client ID of block list. * * @return {Object[]} Post blocks. */ export function getBlocks( state, rootClientId ) { const treeKey = ! rootClientId || ! areInnerBlocksControlled( state, rootClientId ) ? rootClientId || '' : 'controlled||' + rootClientId; return state.blocks.tree[ treeKey ]?.innerBlocks || EMPTY_ARRAY; } /** * Returns a stripped down block object containing only its client ID, * and its inner blocks' client IDs. * * @param {Object} state Editor state. * @param {string} clientId Client ID of the block to get. * * @return {Object} Client IDs of the post blocks. */ export const __unstableGetClientIdWithClientIdsTree = createSelector( ( state, clientId ) => ( { clientId, innerBlocks: __unstableGetClientIdsTree( state, clientId ), } ), ( state ) => [ state.blocks.order ] ); /** * Returns the block tree represented in the block-editor store from the * given root, consisting of stripped down block objects containing only * their client IDs, and their inner blocks' client IDs. * * @param {Object} state Editor state. * @param {?string} rootClientId Optional root client ID of block list. * * @return {Object[]} Client IDs of the post blocks. */ export const __unstableGetClientIdsTree = createSelector( ( state, rootClientId = '' ) => map( getBlockOrder( state, rootClientId ), ( clientId ) => __unstableGetClientIdWithClientIdsTree( state, clientId ) ), ( state ) => [ state.blocks.order ] ); /** * Returns an array containing the clientIds of all descendants * of the blocks given. * * @param {Object} state Global application state. * @param {Array} clientIds Array of blocks to inspect. * * @return {Array} ids of descendants. */ export const getClientIdsOfDescendants = ( state, clientIds ) => flatMap( clientIds, ( clientId ) => { const descendants = getBlockOrder( state, clientId ); return [ ...descendants, ...getClientIdsOfDescendants( state, descendants ), ]; } ); /** * Returns an array containing the clientIds of the top-level blocks * and their descendants of any depth (for nested blocks). * * @param {Object} state Global application state. * * @return {Array} ids of top-level and descendant blocks. */ export const getClientIdsWithDescendants = createSelector( ( state ) => { const topLevelIds = getBlockOrder( state ); return [ ...topLevelIds, ...getClientIdsOfDescendants( state, topLevelIds ), ]; }, ( state ) => [ state.blocks.order ] ); /** * Returns the total number of blocks, or the total number of blocks with a specific name in a post. * The number returned includes nested blocks. * * @param {Object} state Global application state. * @param {?string} blockName Optional block name, if specified only blocks of that type will be counted. * * @return {number} Number of blocks in the post, or number of blocks with name equal to blockName. */ export const getGlobalBlockCount = createSelector( ( state, blockName ) => { const clientIds = getClientIdsWithDescendants( state ); if ( ! blockName ) { return clientIds.length; } return reduce( clientIds, ( accumulator, clientId ) => { const block = state.blocks.byClientId[ clientId ]; return block.name === blockName ? accumulator + 1 : accumulator; }, 0 ); }, ( state ) => [ state.blocks.order, state.blocks.byClientId ] ); /** * Given an array of block client IDs, returns the corresponding array of block * objects. * * @param {Object} state Editor state. * @param {string[]} clientIds Client IDs for which blocks are to be returned. * * @return {GCBlock[]} Block objects. */ export const getBlocksByClientId = createSelector( ( state, clientIds ) => map( castArray( clientIds ), ( clientId ) => getBlock( state, clientId ) ), ( state, clientIds ) => map( castArray( clientIds ), ( clientId ) => state.blocks.tree[ clientId ] ) ); /** * Returns the number of blocks currently present in the post. * * @param {Object} state Editor state. * @param {?string} rootClientId Optional root client ID of block list. * * @return {number} Number of blocks in the post. */ export function getBlockCount( state, rootClientId ) { return getBlockOrder( state, rootClientId ).length; } /** * Returns the current selection start block client ID, attribute key and text * offset. * * @param {Object} state Block editor state. * * @return {GCBlockSelection} Selection start information. */ export function getSelectionStart( state ) { return state.selection.selectionStart; } /** * Returns the current selection end block client ID, attribute key and text * offset. * * @param {Object} state Block editor state. * * @return {GCBlockSelection} Selection end information. */ export function getSelectionEnd( state ) { return state.selection.selectionEnd; } /** * Returns the current block selection start. This value may be null, and it * may represent either a singular block selection or multi-selection start. * A selection is singular if its start and end match. * * @param {Object} state Global application state. * * @return {?string} Client ID of block selection start. */ export function getBlockSelectionStart( state ) { return state.selection.selectionStart.clientId; } /** * Returns the current block selection end. This value may be null, and it * may represent either a singular block selection or multi-selection end. * A selection is singular if its start and end match. * * @param {Object} state Global application state. * * @return {?string} Client ID of block selection end. */ export function getBlockSelectionEnd( state ) { return state.selection.selectionEnd.clientId; } /** * Returns the number of blocks currently selected in the post. * * @param {Object} state Global application state. * * @return {number} Number of blocks selected in the post. */ export function getSelectedBlockCount( state ) { const multiSelectedBlockCount = getMultiSelectedBlockClientIds( state ) .length; if ( multiSelectedBlockCount ) { return multiSelectedBlockCount; } return state.selection.selectionStart.clientId ? 1 : 0; } /** * Returns true if there is a single selected block, or false otherwise. * * @param {Object} state Editor state. * * @return {boolean} Whether a single block is selected. */ export function hasSelectedBlock( state ) { const { selectionStart, selectionEnd } = state.selection; return ( !! selectionStart.clientId && selectionStart.clientId === selectionEnd.clientId ); } /** * Returns the currently selected block client ID, or null if there is no * selected block. * * @param {Object} state Editor state. * * @return {?string} Selected block client ID. */ export function getSelectedBlockClientId( state ) { const { selectionStart, selectionEnd } = state.selection; const { clientId } = selectionStart; if ( ! clientId || clientId !== selectionEnd.clientId ) { return null; } return clientId; } /** * Returns the currently selected block, or null if there is no selected block. * * @param {Object} state Global application state. * * @return {?Object} Selected block. */ export function getSelectedBlock( state ) { const clientId = getSelectedBlockClientId( state ); return clientId ? getBlock( state, clientId ) : null; } /** * Given a block client ID, returns the root block from which the block is * nested, an empty string for top-level blocks, or null if the block does not * exist. * * @param {Object} state Editor state. * @param {string} clientId Block from which to find root client ID. * * @return {?string} Root client ID, if exists */ export function getBlockRootClientId( state, clientId ) { return state.blocks.parents[ clientId ] !== undefined ? state.blocks.parents[ clientId ] : null; } /** * Given a block client ID, returns the list of all its parents from top to bottom. * * @param {Object} state Editor state. * @param {string} clientId Block from which to find root client ID. * @param {boolean} ascending Order results from bottom to top (true) or top to bottom (false). * * @return {Array} ClientIDs of the parent blocks. */ export const getBlockParents = createSelector( ( state, clientId, ascending = false ) => { const parents = []; let current = clientId; while ( !! state.blocks.parents[ current ] ) { current = state.blocks.parents[ current ]; parents.push( current ); } return ascending ? parents : parents.reverse(); }, ( state ) => [ state.blocks.parents ] ); /** * Given a block client ID and a block name, returns the list of all its parents * from top to bottom, filtered by the given name(s). For example, if passed * 'core/group' as the blockName, it will only return parents which are group * blocks. If passed `[ 'core/group', 'core/cover']`, as the blockName, it will * return parents which are group blocks and parents which are cover blocks. * * @param {Object} state Editor state. * @param {string} clientId Block from which to find root client ID. * @param {string|string[]} blockName Block name(s) to filter. * @param {boolean} ascending Order results from bottom to top (true) or top to bottom (false). * * @return {Array} ClientIDs of the parent blocks. */ export const getBlockParentsByBlockName = createSelector( ( state, clientId, blockName, ascending = false ) => { const parents = getBlockParents( state, clientId, ascending ); return map( filter( map( parents, ( id ) => ( { id, name: getBlockName( state, id ), } ) ), ( { name } ) => { if ( Array.isArray( blockName ) ) { return blockName.includes( name ); } return name === blockName; } ), ( { id } ) => id ); }, ( state ) => [ state.blocks.parents ] ); /** * Given a block client ID, returns the root of the hierarchy from which the block is nested, return the block itself for root level blocks. * * @param {Object} state Editor state. * @param {string} clientId Block from which to find root client ID. * * @return {string} Root client ID */ export function getBlockHierarchyRootClientId( state, clientId ) { let current = clientId; let parent; do { parent = current; current = state.blocks.parents[ current ]; } while ( current ); return parent; } /** * Given a block client ID, returns the lowest common ancestor with selected client ID. * * @param {Object} state Editor state. * @param {string} clientId Block from which to find common ancestor client ID. * * @return {string} Common ancestor client ID or undefined */ export function getLowestCommonAncestorWithSelectedBlock( state, clientId ) { const selectedId = getSelectedBlockClientId( state ); const clientParents = [ ...getBlockParents( state, clientId ), clientId ]; const selectedParents = [ ...getBlockParents( state, selectedId ), selectedId, ]; let lowestCommonAncestor; const maxDepth = Math.min( clientParents.length, selectedParents.length ); for ( let index = 0; index < maxDepth; index++ ) { if ( clientParents[ index ] === selectedParents[ index ] ) { lowestCommonAncestor = clientParents[ index ]; } else { break; } } return lowestCommonAncestor; } /** * Returns the client ID of the block adjacent one at the given reference * startClientId and modifier directionality. Defaults start startClientId to * the selected block, and direction as next block. Returns null if there is no * adjacent block. * * @param {Object} state Editor state. * @param {?string} startClientId Optional client ID of block from which to * search. * @param {?number} modifier Directionality multiplier (1 next, -1 * previous). * * @return {?string} Return the client ID of the block, or null if none exists. */ export function getAdjacentBlockClientId( state, startClientId, modifier = 1 ) { // Default to selected block. if ( startClientId === undefined ) { startClientId = getSelectedBlockClientId( state ); } // Try multi-selection starting at extent based on modifier. if ( startClientId === undefined ) { if ( modifier < 0 ) { startClientId = getFirstMultiSelectedBlockClientId( state ); } else { startClientId = getLastMultiSelectedBlockClientId( state ); } } // Validate working start client ID. if ( ! startClientId ) { return null; } // Retrieve start block root client ID, being careful to allow the falsey // empty string top-level root by explicitly testing against null. const rootClientId = getBlockRootClientId( state, startClientId ); if ( rootClientId === null ) { return null; } const { order } = state.blocks; const orderSet = order[ rootClientId ]; const index = orderSet.indexOf( startClientId ); const nextIndex = index + 1 * modifier; // Block was first in set and we're attempting to get previous. if ( nextIndex < 0 ) { return null; } // Block was last in set and we're attempting to get next. if ( nextIndex === orderSet.length ) { return null; } // Assume incremented index is within the set. return orderSet[ nextIndex ]; } /** * Returns the previous block's client ID from the given reference start ID. * Defaults start to the selected block. Returns null if there is no previous * block. * * @param {Object} state Editor state. * @param {?string} startClientId Optional client ID of block from which to * search. * * @return {?string} Adjacent block's client ID, or null if none exists. */ export function getPreviousBlockClientId( state, startClientId ) { return getAdjacentBlockClientId( state, startClientId, -1 ); } /** * Returns the next block's client ID from the given reference start ID. * Defaults start to the selected block. Returns null if there is no next * block. * * @param {Object} state Editor state. * @param {?string} startClientId Optional client ID of block from which to * search. * * @return {?string} Adjacent block's client ID, or null if none exists. */ export function getNextBlockClientId( state, startClientId ) { return getAdjacentBlockClientId( state, startClientId, 1 ); } /* eslint-disable jsdoc/valid-types */ /** * Returns the initial caret position for the selected block. * This position is to used to position the caret properly when the selected block changes. * If the current block is not a RichText, having initial position set to 0 means "focus block" * * @param {Object} state Global application state. * * @return {0|-1|null} Initial position. */ export function getSelectedBlocksInitialCaretPosition( state ) { /* eslint-enable jsdoc/valid-types */ return state.initialPosition; } /** * Returns the current selection set of block client IDs (multiselection or single selection). * * @param {Object} state Editor state. * * @return {Array} Multi-selected block client IDs. */ export const getSelectedBlockClientIds = createSelector( ( state ) => { const { selectionStart, selectionEnd } = state.selection; if ( selectionStart.clientId === undefined || selectionEnd.clientId === undefined ) { return EMPTY_ARRAY; } if ( selectionStart.clientId === selectionEnd.clientId ) { return [ selectionStart.clientId ]; } // Retrieve root client ID to aid in retrieving relevant nested block // order, being careful to allow the falsey empty string top-level root // by explicitly testing against null. const rootClientId = getBlockRootClientId( state, selectionStart.clientId ); if ( rootClientId === null ) { return EMPTY_ARRAY; } const blockOrder = getBlockOrder( state, rootClientId ); const startIndex = blockOrder.indexOf( selectionStart.clientId ); const endIndex = blockOrder.indexOf( selectionEnd.clientId ); if ( startIndex > endIndex ) { return blockOrder.slice( endIndex, startIndex + 1 ); } return blockOrder.slice( startIndex, endIndex + 1 ); }, ( state ) => [ state.blocks.order, state.selection.selectionStart.clientId, state.selection.selectionEnd.clientId, ] ); /** * Returns the current multi-selection set of block client IDs, or an empty * array if there is no multi-selection. * * @param {Object} state Editor state. * * @return {Array} Multi-selected block client IDs. */ export function getMultiSelectedBlockClientIds( state ) { const { selectionStart, selectionEnd } = state.selection; if ( selectionStart.clientId === selectionEnd.clientId ) { return EMPTY_ARRAY; } return getSelectedBlockClientIds( state ); } /** * Returns the current multi-selection set of blocks, or an empty array if * there is no multi-selection. * * @param {Object} state Editor state. * * @return {Array} Multi-selected block objects. */ export const getMultiSelectedBlocks = createSelector( ( state ) => { const multiSelectedBlockClientIds = getMultiSelectedBlockClientIds( state ); if ( ! multiSelectedBlockClientIds.length ) { return EMPTY_ARRAY; } return multiSelectedBlockClientIds.map( ( clientId ) => getBlock( state, clientId ) ); }, ( state ) => [ ...getSelectedBlockClientIds.getDependants( state ), state.blocks.byClientId, state.blocks.order, state.blocks.attributes, ] ); /** * Returns the client ID of the first block in the multi-selection set, or null * if there is no multi-selection. * * @param {Object} state Editor state. * * @return {?string} First block client ID in the multi-selection set. */ export function getFirstMultiSelectedBlockClientId( state ) { return first( getMultiSelectedBlockClientIds( state ) ) || null; } /** * Returns the client ID of the last block in the multi-selection set, or null * if there is no multi-selection. * * @param {Object} state Editor state. * * @return {?string} Last block client ID in the multi-selection set. */ export function getLastMultiSelectedBlockClientId( state ) { return last( getMultiSelectedBlockClientIds( state ) ) || null; } /** * Returns true if a multi-selection exists, and the block corresponding to the * specified client ID is the first block of the multi-selection set, or false * otherwise. * * @param {Object} state Editor state. * @param {string} clientId Block client ID. * * @return {boolean} Whether block is first in multi-selection. */ export function isFirstMultiSelectedBlock( state, clientId ) { return getFirstMultiSelectedBlockClientId( state ) === clientId; } /** * Returns true if the client ID occurs within the block multi-selection, or * false otherwise. * * @param {Object} state Editor state. * @param {string} clientId Block client ID. * * @return {boolean} Whether block is in multi-selection set. */ export function isBlockMultiSelected( state, clientId ) { return getMultiSelectedBlockClientIds( state ).indexOf( clientId ) !== -1; } /** * Returns true if an ancestor of the block is multi-selected, or false * otherwise. * * @param {Object} state Editor state. * @param {string} clientId Block client ID. * * @return {boolean} Whether an ancestor of the block is in multi-selection * set. */ export const isAncestorMultiSelected = createSelector( ( state, clientId ) => { let ancestorClientId = clientId; let isMultiSelected = false; while ( ancestorClientId && ! isMultiSelected ) { ancestorClientId = getBlockRootClientId( state, ancestorClientId ); isMultiSelected = isBlockMultiSelected( state, ancestorClientId ); } return isMultiSelected; }, ( state ) => [ state.blocks.order, state.selection.selectionStart.clientId, state.selection.selectionEnd.clientId, ] ); /** * Returns the client ID of the block which begins the multi-selection set, or * null if there is no multi-selection. * * This is not necessarily the first client ID in the selection. * * @see getFirstMultiSelectedBlockClientId * * @param {Object} state Editor state. * * @return {?string} Client ID of block beginning multi-selection. */ export function getMultiSelectedBlocksStartClientId( state ) { const { selectionStart, selectionEnd } = state.selection; if ( selectionStart.clientId === selectionEnd.clientId ) { return null; } return selectionStart.clientId || null; } /** * Returns the client ID of the block which ends the multi-selection set, or * null if there is no multi-selection. * * This is not necessarily the last client ID in the selection. * * @see getLastMultiSelectedBlockClientId * * @param {Object} state Editor state. * * @return {?string} Client ID of block ending multi-selection. */ export function getMultiSelectedBlocksEndClientId( state ) { const { selectionStart, selectionEnd } = state.selection; if ( selectionStart.clientId === selectionEnd.clientId ) { return null; } return selectionEnd.clientId || null; } /** * Returns an array containing all block client IDs in the editor in the order * they appear. Optionally accepts a root client ID of the block list for which * the order should be returned, defaulting to the top-level block order. * * @param {Object} state Editor state. * @param {?string} rootClientId Optional root client ID of block list. * * @return {Array} Ordered client IDs of editor blocks. */ export function getBlockOrder( state, rootClientId ) { return state.blocks.order[ rootClientId || '' ] || EMPTY_ARRAY; } /** * Returns the index at which the block corresponding to the specified client * ID occurs within the block order, or `-1` if the block does not exist. * * @param {Object} state Editor state. * @param {string} clientId Block client ID. * * @return {number} Index at which block exists in order. */ export function getBlockIndex( state, clientId ) { const rootClientId = getBlockRootClientId( state, clientId ); return getBlockOrder( state, rootClientId ).indexOf( clientId ); } /** * Returns true if the block corresponding to the specified client ID is * currently selected and no multi-selection exists, or false otherwise. * * @param {Object} state Editor state. * @param {string} clientId Block client ID. * * @return {boolean} Whether block is selected and multi-selection exists. */ export function isBlockSelected( state, clientId ) { const { selectionStart, selectionEnd } = state.selection; if ( selectionStart.clientId !== selectionEnd.clientId ) { return false; } return selectionStart.clientId === clientId; } /** * Returns true if one of the block's inner blocks is selected. * * @param {Object} state Editor state. * @param {string} clientId Block client ID. * @param {boolean} deep Perform a deep check. * * @return {boolean} Whether the block as an inner block selected */ export function hasSelectedInnerBlock( state, clientId, deep = false ) { return some( getBlockOrder( state, clientId ), ( innerClientId ) => isBlockSelected( state, innerClientId ) || isBlockMultiSelected( state, innerClientId ) || ( deep && hasSelectedInnerBlock( state, innerClientId, deep ) ) ); } /** * Returns true if the block corresponding to the specified client ID is * currently selected but isn't the last of the selected blocks. Here "last" * refers to the block sequence in the document, _not_ the sequence of * multi-selection, which is why `state.selectionEnd` isn't used. * * @param {Object} state Editor state. * @param {string} clientId Block client ID. * * @return {boolean} Whether block is selected and not the last in the * selection. */ export function isBlockWithinSelection( state, clientId ) { if ( ! clientId ) { return false; } const clientIds = getMultiSelectedBlockClientIds( state ); const index = clientIds.indexOf( clientId ); return index > -1 && index < clientIds.length - 1; } /** * Returns true if a multi-selection has been made, or false otherwise. * * @param {Object} state Editor state. * * @return {boolean} Whether multi-selection has been made. */ export function hasMultiSelection( state ) { const { selectionStart, selectionEnd } = state.selection; return selectionStart.clientId !== selectionEnd.clientId; } /** * Whether in the process of multi-selecting or not. This flag is only true * while the multi-selection is being selected (by mouse move), and is false * once the multi-selection has been settled. * * @see hasMultiSelection * * @param {Object} state Global application state. * * @return {boolean} True if multi-selecting, false if not. */ export function isMultiSelecting( state ) { return state.isMultiSelecting; } /** * Selector that returns if multi-selection is enabled or not. * * @param {Object} state Global application state. * * @return {boolean} True if it should be possible to multi-select blocks, false if multi-selection is disabled. */ export function isSelectionEnabled( state ) { return state.isSelectionEnabled; } /** * Returns the block's editing mode, defaulting to "visual" if not explicitly * assigned. * * @param {Object} state Editor state. * @param {string} clientId Block client ID. * * @return {Object} Block editing mode. */ export function getBlockMode( state, clientId ) { return state.blocksMode[ clientId ] || 'visual'; } /** * Returns true if the user is typing, or false otherwise. * * @param {Object} state Global application state. * * @return {boolean} Whether user is typing. */ export function isTyping( state ) { return state.isTyping; } /** * Returns true if the user is dragging blocks, or false otherwise. * * @param {Object} state Global application state. * * @return {boolean} Whether user is dragging blocks. */ export function isDraggingBlocks( state ) { return !! state.draggedBlocks.length; } /** * Returns the client ids of any blocks being directly dragged. * * This does not include children of a parent being dragged. * * @param {Object} state Global application state. * * @return {string[]} Array of dragged block client ids. */ export function getDraggedBlockClientIds( state ) { return state.draggedBlocks; } /** * Returns whether the block is being dragged. * * Only returns true if the block is being directly dragged, * not if the block is a child of a parent being dragged. * See `isAncestorBeingDragged` for child blocks. * * @param {Object} state Global application state. * @param {string} clientId Client id for block to check. * * @return {boolean} Whether the block is being dragged. */ export function isBlockBeingDragged( state, clientId ) { return state.draggedBlocks.includes( clientId ); } /** * Returns whether a parent/ancestor of the block is being dragged. * * @param {Object} state Global application state. * @param {string} clientId Client id for block to check. * * @return {boolean} Whether the block's ancestor is being dragged. */ export function isAncestorBeingDragged( state, clientId ) { // Return early if no blocks are being dragged rather than // the more expensive check for parents. if ( ! isDraggingBlocks( state ) ) { return false; } const parents = getBlockParents( state, clientId ); return some( parents, ( parentClientId ) => isBlockBeingDragged( state, parentClientId ) ); } /** * Returns true if the caret is within formatted text, or false otherwise. * * @param {Object} state Global application state. * * @return {boolean} Whether the caret is within formatted text. */ export function isCaretWithinFormattedText( state ) { return state.isCaretWithinFormattedText; } /** * Returns the insertion point, the index at which the new inserted block would * be placed. Defaults to the last index. * * @param {Object} state Editor state. * * @return {Object} Insertion point object with `rootClientId`, `index`. */ export function getBlockInsertionPoint( state ) { let rootClientId, index; const { insertionPoint, selection: { selectionEnd }, } = state; if ( insertionPoint !== null ) { return insertionPoint; } const { clientId } = selectionEnd; if ( clientId ) { rootClientId = getBlockRootClientId( state, clientId ) || undefined; index = getBlockIndex( state, selectionEnd.clientId, rootClientId ) + 1; } else { index = getBlockOrder( state ).length; } return { rootClientId, index }; } /** * Returns true if we should show the block insertion point. * * @param {Object} state Global application state. * * @return {?boolean} Whether the insertion point is visible or not. */ export function isBlockInsertionPointVisible( state ) { return state.insertionPoint !== null; } /** * Returns whether the blocks matches the template or not. * * @param {boolean} state * @return {?boolean} Whether the template is valid or not. */ export function isValidTemplate( state ) { return state.template.isValid; } /** * Returns the defined block template * * @param {boolean} state * * @return {?Array} Block Template. */ export function getTemplate( state ) { return state.settings.template; } /** * Returns the defined block template lock. Optionally accepts a root block * client ID as context, otherwise defaulting to the global context. * * @param {Object} state Editor state. * @param {?string} rootClientId Optional block root client ID. * * @return {?string} Block Template Lock */ export function getTemplateLock( state, rootClientId ) { if ( ! rootClientId ) { return state.settings.templateLock; } const blockListSettings = getBlockListSettings( state, rootClientId ); if ( ! blockListSettings ) { return null; } return blockListSettings.templateLock; } const checkAllowList = ( list, item, defaultResult = null ) => { if ( isBoolean( list ) ) { return list; } if ( isArray( list ) ) { // TODO: when there is a canonical way to detect that we are editing a post // the following check should be changed to something like: // if ( list.includes( 'core/post-content' ) && getEditorMode() === 'post-content' && item === null ) if ( list.includes( 'core/post-content' ) && item === null ) { return true; } return list.includes( item ); } return defaultResult; }; /** * Determines if the given block type is allowed to be inserted into the block list. * This function is not exported and not memoized because using a memoized selector * inside another memoized selector is just a waste of time. * * @param {Object} state Editor state. * @param {string|Object} blockName The block type object, e.g., the response * from the block directory; or a string name of * an installed block type, e.g.' core/paragraph'. * @param {?string} rootClientId Optional root client ID of block list. * * @return {boolean} Whether the given block type is allowed to be inserted. */ const canInsertBlockTypeUnmemoized = ( state, blockName, rootClientId = null ) => { let blockType; if ( blockName && 'object' === typeof blockName ) { blockType = blockName; blockName = blockType.name; } else { blockType = getBlockType( blockName ); } if ( ! blockType ) { return false; } const { allowedBlockTypes } = getSettings( state ); const isBlockAllowedInEditor = checkAllowList( allowedBlockTypes, blockName, true ); if ( ! isBlockAllowedInEditor ) { return false; } const isLocked = !! getTemplateLock( state, rootClientId ); if ( isLocked ) { return false; } const parentBlockListSettings = getBlockListSettings( state, rootClientId ); // The parent block doesn't have settings indicating it doesn't support // inner blocks, return false. if ( rootClientId && parentBlockListSettings === undefined ) { return false; } const parentAllowedBlocks = parentBlockListSettings?.allowedBlocks; const hasParentAllowedBlock = checkAllowList( parentAllowedBlocks, blockName ); const blockAllowedParentBlocks = blockType.parent; const parentName = getBlockName( state, rootClientId ); const hasBlockAllowedParent = checkAllowList( blockAllowedParentBlocks, parentName ); const canInsert = ( hasParentAllowedBlock === null && hasBlockAllowedParent === null ) || hasParentAllowedBlock === true || hasBlockAllowedParent === true; if ( ! canInsert ) { return canInsert; } /** * This filter is an ad-hoc solution to prevent adding template parts inside post content. * Conceptually, having a filter inside a selector is bad pattern so this code will be * replaced by a declarative API that doesn't the following drawbacks: * * Filters are not reactive: Upon switching between "template mode" and non "template mode", * the filter and selector won't necessarily be executed again. For now, it doesn't matter much * because you can't switch between the two modes while the inserter stays open. * * Filters are global: Once they're defined, they will affect all editor instances and all registries. * An ideal API would only affect specific editor instances. */ return applyFilters( 'blockEditor.__unstableCanInsertBlockType', canInsert, blockType, rootClientId, { // Pass bound selectors of the current registry. If we're in a nested // context, the data will differ from the one selected from the root // registry. getBlock: getBlock.bind( null, state ), getBlockParentsByBlockName: getBlockParentsByBlockName.bind( null, state ), } ); }; /** * Determines if the given block type is allowed to be inserted into the block list. * * @param {Object} state Editor state. * @param {string} blockName The name of the block type, e.g.' core/paragraph'. * @param {?string} rootClientId Optional root client ID of block list. * * @return {boolean} Whether the given block type is allowed to be inserted. */ export const canInsertBlockType = createSelector( canInsertBlockTypeUnmemoized, ( state, blockName, rootClientId ) => [ state.blockListSettings[ rootClientId ], state.blocks.byClientId[ rootClientId ], state.settings.allowedBlockTypes, state.settings.templateLock, ] ); /** * Determines if the given blocks are allowed to be inserted into the block * list. * * @param {Object} state Editor state. * @param {string} clientIds The block client IDs to be inserted. * @param {?string} rootClientId Optional root client ID of block list. * * @return {boolean} Whether the given blocks are allowed to be inserted. */ export function canInsertBlocks( state, clientIds, rootClientId = null ) { return clientIds.every( ( id ) => canInsertBlockType( state, getBlockName( state, id ), rootClientId ) ); } /** * Determines if the given block is allowed to be deleted. * * @param {Object} state Editor state. * @param {string} clientId The block client Id. * @param {?string} rootClientId Optional root client ID of block list. * * @return {boolean} Whether the given block is allowed to be removed. */ export function canRemoveBlock( state, clientId, rootClientId = null ) { const attributes = getBlockAttributes( state, clientId ); // attributes can be null if the block is already deleted. if ( attributes === null ) { return true; } const { lock } = attributes; const parentIsLocked = !! getTemplateLock( state, rootClientId ); // If we don't have a lock on the blockType level, we differ to the parent templateLock. if ( lock === undefined || lock?.remove === undefined ) { return ! parentIsLocked; } // when remove is true, it means we cannot remove it. return ! lock?.remove; } /** * Determines if the given blocks are allowed to be removed. * * @param {Object} state Editor state. * @param {string} clientIds The block client IDs to be removed. * @param {?string} rootClientId Optional root client ID of block list. * * @return {boolean} Whether the given blocks are allowed to be removed. */ export function canRemoveBlocks( state, clientIds, rootClientId = null ) { return clientIds.every( ( clientId ) => canRemoveBlock( state, clientId, rootClientId ) ); } /** * Determines if the given block is allowed to be moved. * * @param {Object} state Editor state. * @param {string} clientId The block client Id. * @param {?string} rootClientId Optional root client ID of block list. * * @return {boolean} Whether the given block is allowed to be moved. */ export function canMoveBlock( state, clientId, rootClientId = null ) { const attributes = getBlockAttributes( state, clientId ); if ( attributes === null ) { return; } const { lock } = attributes; const parentIsLocked = getTemplateLock( state, rootClientId ) === 'all'; // If we don't have a lock on the blockType level, we differ to the parent templateLock. if ( lock === undefined || lock?.move === undefined ) { return ! parentIsLocked; } // when move is true, it means we cannot move it. return ! lock?.move; } /** * Determines if the given blocks are allowed to be moved. * * @param {Object} state Editor state. * @param {string} clientIds The block client IDs to be moved. * @param {?string} rootClientId Optional root client ID of block list. * * @return {boolean} Whether the given blocks are allowed to be moved. */ export function canMoveBlocks( state, clientIds, rootClientId = null ) { return clientIds.every( ( clientId ) => canMoveBlock( state, clientId, rootClientId ) ); } /** * Returns information about how recently and frequently a block has been inserted. * * @param {Object} state Global application state. * @param {string} id A string which identifies the insert, e.g. 'core/block/12' * * @return {?{ time: number, count: number }} An object containing `time` which is when the last * insert occurred as a UNIX epoch, and `count` which is * the number of inserts that have occurred. */ function getInsertUsage( state, id ) { return state.preferences.insertUsage?.[ id ] ?? null; } /** * Returns whether we can show a block type in the inserter * * @param {Object} state Global State * @param {Object} blockType BlockType * @param {?string} rootClientId Optional root client ID of block list. * * @return {boolean} Whether the given block type is allowed to be shown in the inserter. */ const canIncludeBlockTypeInInserter = ( state, blockType, rootClientId ) => { if ( ! hasBlockSupport( blockType, 'inserter', true ) ) { return false; } return canInsertBlockTypeUnmemoized( state, blockType.name, rootClientId ); }; /** * Return a function to be used to tranform a block variation to an inserter item * * @param {Object} state Global State * @param {Object} item Denormalized inserter item * @return {Function} Function to transform a block variation to inserter item */ const getItemFromVariation = ( state, item ) => ( variation ) => { const variationId = `${ item.id }/${ variation.name }`; const { time, count = 0 } = getInsertUsage( state, variationId ) || {}; return { ...item, id: variationId, icon: variation.icon || item.icon, title: variation.title || item.title, description: variation.description || item.description, category: variation.category || item.category, // If `example` is explicitly undefined for the variation, the preview will not be shown. example: variation.hasOwnProperty( 'example' ) ? variation.example : item.example, initialAttributes: { ...item.initialAttributes, ...variation.attributes, }, innerBlocks: variation.innerBlocks, keywords: variation.keywords || item.keywords, frecency: calculateFrecency( time, count ), }; }; /** * Returns the calculated frecency. * * 'frecency' is a heuristic (https://en.wikipedia.org/wiki/Frecency) * that combines block usage frequenty and recency. * * @param {number} time When the last insert occurred as a UNIX epoch * @param {number} count The number of inserts that have occurred. * * @return {number} The calculated frecency. */ const calculateFrecency = ( time, count ) => { if ( ! time ) { return count; } // The selector is cached, which means Date.now() is the last time that the // relevant state changed. This suits our needs. const duration = Date.now() - time; switch ( true ) { case duration < MILLISECONDS_PER_HOUR: return count * 4; case duration < MILLISECONDS_PER_DAY: return count * 2; case duration < MILLISECONDS_PER_WEEK: return count / 2; default: return count / 4; } }; /** * Returns a function that accepts a block type and builds an item to be shown * in a specific context. It's used for building items for Inserter and available * block Transfroms list. * * @param {Object} state Editor state. * @param {Object} options Options object for handling the building of a block type. * @param {string} options.buildScope The scope for which the item is going to be used. * @return {Function} Function returns an item to be shown in a specific context (Inserter|Transforms list). */ const buildBlockTypeItem = ( state, { buildScope = 'inserter' } ) => ( blockType ) => { const id = blockType.name; let isDisabled = false; if ( ! hasBlockSupport( blockType.name, 'multiple', true ) ) { isDisabled = some( getBlocksByClientId( state, getClientIdsWithDescendants( state ) ), { name: blockType.name } ); } const { time, count = 0 } = getInsertUsage( state, id ) || {}; const blockItemBase = { id, name: blockType.name, title: blockType.title, icon: blockType.icon, isDisabled, frecency: calculateFrecency( time, count ), }; if ( buildScope === 'transform' ) return blockItemBase; const inserterVariations = getBlockVariations( blockType.name, 'inserter' ); return { ...blockItemBase, initialAttributes: {}, description: blockType.description, category: blockType.category, keywords: blockType.keywords, variations: inserterVariations, example: blockType.example, utility: 1, // deprecated }; }; /** * Determines the items that appear in the inserter. Includes both static * items (e.g. a regular block type) and dynamic items (e.g. a reusable block). * * Each item object contains what's necessary to display a button in the * inserter and handle its selection. * * The 'frecency' property is a heuristic (https://en.wikipedia.org/wiki/Frecency) * that combines block usage frequenty and recency. * * Items are returned ordered descendingly by their 'utility' and 'frecency'. * * @param {Object} state Editor state. * @param {?string} rootClientId Optional root client ID of block list. * * @return {GCEditorInserterItem[]} Items that appear in inserter. * * @typedef {Object} GCEditorInserterItem * @property {string} id Unique identifier for the item. * @property {string} name The type of block to create. * @property {Object} initialAttributes Attributes to pass to the newly created block. * @property {string} title Title of the item, as it appears in the inserter. * @property {string} icon Dashicon for the item, as it appears in the inserter. * @property {string} category Block category that the item is associated with. * @property {string[]} keywords Keywords that can be searched to find this item. * @property {boolean} isDisabled Whether or not the user should be prevented from inserting * this item. * @property {number} frecency Heuristic that combines frequency and recency. */ export const getInserterItems = createSelector( ( state, rootClientId = null ) => { const buildBlockTypeInserterItem = buildBlockTypeItem( state, { buildScope: 'inserter', } ); /* * Matches block comment delimiters amid serialized content. * * @see `tokenizer` in `@gechiui/block-serialization-default-parser` * package * * blockParserTokenizer differs from the original tokenizer in the * following ways: * * - removed global flag (/g) * - prepended ^\s* * */ const blockParserTokenizer = /^\s*<!--\s+(\/)?gc:([a-z][a-z0-9_-]*\/)?([a-z][a-z0-9_-]*)\s+({(?:(?=([^}]+|}+(?=})|(?!}\s+\/?-->)[^])*)\5|[^]*?)}\s+)?(\/)?-->/; const buildReusableBlockInserterItem = ( reusableBlock ) => { let icon = symbol; /* * Instead of always displaying a generic "symbol" icon for every * reusable block, try to use an icon that represents the first * outermost block contained in the reusable block. This requires * scanning the serialized form of the reusable block to find its * first block delimiter, then looking up the corresponding block * type, if available. */ if ( Platform.OS === 'web' ) { const content = typeof reusableBlock.content.raw === 'string' ? reusableBlock.content.raw : reusableBlock.content; const rawBlockMatch = content.match( blockParserTokenizer ); if ( rawBlockMatch ) { const [ , , namespace = 'core/', blockName, ] = rawBlockMatch; const referen