UNPKG

@frontkom/gutenberg-js

Version:

gutenberg-js - An extension of the Wordpress Gutenberg editor

405 lines (357 loc) 11.6 kB
/** * External dependencies */ import { filter, find, findIndex, flow, groupBy, isEmpty, map, some, sortBy, without, includes, deburr, } from 'lodash'; import scrollIntoView from 'dom-scroll-into-view'; /** * WordPress dependencies */ import { __, _n, _x, sprintf } from '@wordpress/i18n'; import { Component, createRef } from '@wordpress/element'; import { withSpokenMessages, PanelBody } from '@wordpress/components'; import { getCategories, isReusableBlock, createBlock, isUnmodifiedDefaultBlock, } from '@wordpress/blocks'; import { withDispatch, withSelect } from '@wordpress/data'; import { withInstanceId, compose, withSafeTimeout } from '@wordpress/compose'; import { LEFT, RIGHT, UP, DOWN, BACKSPACE, ENTER } from '@wordpress/keycodes'; // GUTENBERG JS - use addQueryArgs instead of hard coding // 'edit.php?post_type=wp_block' import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies */ import BlockPreview from '../block-preview'; import BlockTypesList from '../block-types-list'; import ChildBlocks from './child-blocks'; import InserterInlineElements from './inline-elements'; const MAX_SUGGESTED_ITEMS = 9; const stopKeyPropagation = ( event ) => event.stopPropagation(); /** * Filters an item list given a search term. * * @param {Array} items Item list * @param {string} searchTerm Search term. * * @return {Array} Filtered item list. */ export const searchItems = ( items, searchTerm ) => { const normalizedSearchTerm = normalizeTerm( searchTerm ); const matchSearch = ( string ) => normalizeTerm( string ).indexOf( normalizedSearchTerm ) !== -1; const categories = getCategories(); return items.filter( ( item ) => { const itemCategory = find( categories, { slug: item.category } ); return matchSearch( item.title ) || some( item.keywords, matchSearch ) || ( itemCategory && matchSearch( itemCategory.title ) ); } ); }; /** * Converts the search term into a normalized term. * * @param {string} term The search term to normalize. * * @return {string} The normalized search term. */ export const normalizeTerm = ( term ) => { // Disregard diacritics. // Input: "média" term = deburr( term ); // Accommodate leading slash, matching autocomplete expectations. // Input: "/media" term = term.replace( /^\//, '' ); // Lowercase. // Input: "MEDIA" term = term.toLowerCase(); // Strip leading and trailing whitespace. // Input: " media " term = term.trim(); return term; }; export class InserterMenu extends Component { constructor() { super( ...arguments ); this.state = { childItems: [], filterValue: '', hoveredItem: null, suggestedItems: [], reusableItems: [], itemsPerCategory: {}, openPanels: [ 'suggested' ], }; this.onChangeSearchInput = this.onChangeSearchInput.bind( this ); this.onHover = this.onHover.bind( this ); this.panels = {}; this.inserterResults = createRef(); } componentDidMount() { // This could be replaced by a resolver. this.props.fetchReusableBlocks(); this.filter(); } componentDidUpdate( prevProps ) { if ( prevProps.items !== this.props.items ) { this.filter( this.state.filterValue ); } } onChangeSearchInput( event ) { this.filter( event.target.value ); } onHover( item ) { this.setState( { hoveredItem: item, } ); const { showInsertionPoint, hideInsertionPoint } = this.props; if ( item ) { const { rootClientId, index } = this.props; showInsertionPoint( rootClientId, index ); } else { hideInsertionPoint(); } } bindPanel( name ) { return ( ref ) => { this.panels[ name ] = ref; }; } onTogglePanel( panel ) { return () => { const isOpened = this.state.openPanels.indexOf( panel ) !== -1; if ( isOpened ) { this.setState( { openPanels: without( this.state.openPanels, panel ), } ); } else { this.setState( { openPanels: [ ...this.state.openPanels, panel, ], } ); this.props.setTimeout( () => { // We need a generic way to access the panel's container // eslint-disable-next-line react/no-find-dom-node scrollIntoView( this.panels[ panel ], this.inserterResults.current, { alignWithTop: true, } ); } ); } }; } filter( filterValue = '' ) { const { debouncedSpeak, items, rootChildBlocks } = this.props; const filteredItems = searchItems( items, filterValue ); const childItems = filter( filteredItems, ( { name } ) => includes( rootChildBlocks, name ) ); let suggestedItems = []; if ( ! filterValue ) { const maxSuggestedItems = this.props.maxSuggestedItems || MAX_SUGGESTED_ITEMS; suggestedItems = filter( items, ( item ) => item.utility > 0 ).slice( 0, maxSuggestedItems ); } const reusableItems = filter( filteredItems, { category: 'reusable' } ); const getCategoryIndex = ( item ) => { return findIndex( getCategories(), ( category ) => category.slug === item.category ); }; const itemsPerCategory = flow( ( itemList ) => filter( itemList, ( item ) => item.category !== 'reusable' ), ( itemList ) => sortBy( itemList, getCategoryIndex ), ( itemList ) => groupBy( itemList, 'category' ) )( filteredItems ); let openPanels = this.state.openPanels; if ( filterValue !== this.state.filterValue ) { if ( ! filterValue ) { openPanels = [ 'suggested' ]; } else if ( reusableItems.length ) { openPanels = [ 'reusable' ]; } else if ( filteredItems.length ) { const firstCategory = find( getCategories(), ( { slug } ) => itemsPerCategory[ slug ] && itemsPerCategory[ slug ].length ); openPanels = [ firstCategory.slug ]; } } this.setState( { hoveredItem: null, childItems, filterValue, suggestedItems, reusableItems, itemsPerCategory, openPanels, } ); const resultCount = Object.keys( itemsPerCategory ).reduce( ( accumulator, currentCategorySlug ) => { return accumulator + itemsPerCategory[ currentCategorySlug ].length; }, 0 ); const resultsFoundMessage = sprintf( _n( '%d result found.', '%d results found.', resultCount ), resultCount ); debouncedSpeak( resultsFoundMessage, 'assertive' ); } onKeyDown( event ) { if ( includes( [ LEFT, DOWN, RIGHT, UP, BACKSPACE, ENTER ], event.keyCode ) ) { // Stop the key event from propagating up to ObserveTyping.startTypingInTextField. event.stopPropagation(); } } render() { const { instanceId, onSelect, rootClientId } = this.props; const { childItems, filterValue, hoveredItem, suggestedItems, reusableItems, itemsPerCategory, openPanels } = this.state; const isPanelOpen = ( panel ) => openPanels.indexOf( panel ) !== -1; const isSearching = !! filterValue; // Disable reason (no-autofocus): The inserter menu is a modal display, not one which // is always visible, and one which already incurs this behavior of autoFocus via // Popover's focusOnMount. // Disable reason (no-static-element-interactions): Navigational key-presses within // the menu are prevented from triggering WritingFlow and ObserveTyping interactions. /* eslint-disable jsx-a11y/no-autofocus, jsx-a11y/no-static-element-interactions */ return ( <div className="editor-inserter__menu" onKeyPress={ stopKeyPropagation } onKeyDown={ this.onKeyDown } > <label htmlFor={ `editor-inserter__search-${ instanceId }` } className="screen-reader-text"> { __( 'Search for a block' ) } </label> <input id={ `editor-inserter__search-${ instanceId }` } type="search" placeholder={ __( 'Search for a block' ) } className="editor-inserter__search" autoFocus onChange={ this.onChangeSearchInput } /> <div className="editor-inserter__results" ref={ this.inserterResults } tabIndex="0" role="region" aria-label={ __( 'Available block types' ) } > <ChildBlocks rootClientId={ rootClientId } items={ childItems } onSelect={ onSelect } onHover={ this.onHover } /> { !! suggestedItems.length && <PanelBody title={ _x( 'Most Used', 'blocks' ) } opened={ isPanelOpen( 'suggested' ) } onToggle={ this.onTogglePanel( 'suggested' ) } ref={ this.bindPanel( 'suggested' ) } > <BlockTypesList items={ suggestedItems } onSelect={ onSelect } onHover={ this.onHover } /> </PanelBody> } <InserterInlineElements filterValue={ filterValue } /> { map( getCategories(), ( category ) => { const categoryItems = itemsPerCategory[ category.slug ]; if ( ! categoryItems || ! categoryItems.length ) { return null; } return ( <PanelBody key={ category.slug } title={ category.title } icon={ category.icon } opened={ isSearching || isPanelOpen( category.slug ) } onToggle={ this.onTogglePanel( category.slug ) } ref={ this.bindPanel( category.slug ) } > <BlockTypesList items={ categoryItems } onSelect={ onSelect } onHover={ this.onHover } /> </PanelBody> ); } ) } { !! reusableItems.length && ( <PanelBody className="editor-inserter__reusable-blocks-panel" title={ __( 'Reusable' ) } opened={ isPanelOpen( 'reusable' ) } onToggle={ this.onTogglePanel( 'reusable' ) } icon="controls-repeat" ref={ this.bindPanel( 'reusable' ) } > <BlockTypesList items={ reusableItems } onSelect={ onSelect } onHover={ this.onHover } /> <a className="editor-inserter__manage-reusable-blocks" href={ addQueryArgs( 'edit.php', { post_type: 'wp_block' } ) } > { __( 'Manage All Reusable Blocks' ) } </a> </PanelBody> ) } { isEmpty( suggestedItems ) && isEmpty( reusableItems ) && isEmpty( itemsPerCategory ) && ( <p className="editor-inserter__no-results">{ __( 'No blocks found.' ) }</p> ) } </div> { hoveredItem && isReusableBlock( hoveredItem ) && <BlockPreview name={ hoveredItem.name } attributes={ hoveredItem.initialAttributes } /> } </div> ); /* eslint-enable jsx-a11y/no-autofocus, jsx-a11y/no-noninteractive-element-interactions */ } } export default compose( withSelect( ( select, { rootClientId } ) => { const { getInserterItems, getBlockName, } = select( 'core/editor' ); const { getChildBlockNames, } = select( 'core/blocks' ); const rootBlockName = getBlockName( rootClientId ); return { rootChildBlocks: getChildBlockNames( rootBlockName ), items: getInserterItems( rootClientId ), rootClientId, }; } ), withDispatch( ( dispatch, ownProps, { select } ) => { const { __experimentalFetchReusableBlocks: fetchReusableBlocks, showInsertionPoint, hideInsertionPoint, } = dispatch( 'core/editor' ); return { fetchReusableBlocks, showInsertionPoint, hideInsertionPoint, onSelect( item ) { const { replaceBlocks, insertBlock, } = dispatch( 'core/editor' ); const { getSelectedBlock } = select( 'core/editor' ); const { index, rootClientId } = ownProps; const { name, initialAttributes } = item; const selectedBlock = getSelectedBlock(); const insertedBlock = createBlock( name, initialAttributes ); if ( selectedBlock && isUnmodifiedDefaultBlock( selectedBlock ) ) { replaceBlocks( selectedBlock.clientId, insertedBlock ); } else { insertBlock( insertedBlock, index, rootClientId ); } ownProps.onSelect(); }, }; } ), withSpokenMessages, withInstanceId, withSafeTimeout )( InserterMenu );