UNPKG

@gechiui/block-editor

Version:
367 lines (329 loc) 12 kB
/** * External dependencies */ import { noop } from 'lodash'; import classnames from 'classnames'; /** * GeChiUI dependencies */ import { Button, Spinner, Notice, TextControl } from '@gechiui/components'; import { keyboardReturn } from '@gechiui/icons'; import { __ } from '@gechiui/i18n'; import { useRef, useState, useEffect } from '@gechiui/element'; import { focus } from '@gechiui/dom'; import { ENTER } from '@gechiui/keycodes'; /** * Internal dependencies */ import LinkControlSettingsDrawer from './settings-drawer'; import LinkControlSearchInput from './search-input'; import LinkPreview from './link-preview'; import useCreatePage from './use-create-page'; import { ViewerFill } from './viewer-slot'; import { DEFAULT_LINK_SETTINGS } from './constants'; /** * Default properties associated with a link control value. * * @typedef GCLinkControlDefaultValue * * @property {string} url Link URL. * @property {string=} title Link title. * @property {boolean=} opensInNewTab Whether link should open in a new browser * tab. This value is only assigned if not * providing a custom `settings` prop. */ /* eslint-disable jsdoc/valid-types */ /** * Custom settings values associated with a link. * * @typedef {{[setting:string]:any}} GCLinkControlSettingsValue */ /* eslint-enable */ /** * Custom settings values associated with a link. * * @typedef GCLinkControlSetting * * @property {string} id Identifier to use as property for setting value. * @property {string} title Human-readable label to show in user interface. */ /** * Properties associated with a link control value, composed as a union of the * default properties and any custom settings values. * * @typedef {GCLinkControlDefaultValue&GCLinkControlSettingsValue} GCLinkControlValue */ /** @typedef {(nextValue:GCLinkControlValue)=>void} GCLinkControlOnChangeProp */ /** * Properties associated with a search suggestion used within the LinkControl. * * @typedef GCLinkControlSuggestion * * @property {string} id Identifier to use to uniquely identify the suggestion. * @property {string} type Identifies the type of the suggestion (eg: `post`, * `page`, `url`...etc) * @property {string} title Human-readable label to show in user interface. * @property {string} url A URL for the suggestion. */ /** @typedef {(title:string)=>GCLinkControlSuggestion} GCLinkControlCreateSuggestionProp */ /** * @typedef GCLinkControlProps * * @property {(GCLinkControlSetting[])=} settings An array of settings objects. Each object will used to * render a `ToggleControl` for that setting. * @property {boolean=} forceIsEditingLink If passed as either `true` or `false`, controls the * internal editing state of the component to respective * show or not show the URL input field. * @property {GCLinkControlValue=} value Current link value. * @property {GCLinkControlOnChangeProp=} onChange Value change handler, called with the updated value if * the user selects a new link or updates settings. * @property {boolean=} noDirectEntry Whether to allow turning a URL-like search query directly into a link. * @property {boolean=} showSuggestions Whether to present suggestions when typing the URL. * @property {boolean=} showInitialSuggestions Whether to present initial suggestions immediately. * @property {boolean=} withCreateSuggestion Whether to allow creation of link value from suggestion. * @property {Object=} suggestionsQuery Query parameters to pass along to gc.blockEditor.__experimentalFetchLinkSuggestions. * @property {boolean=} noURLSuggestion Whether to add a fallback suggestion which treats the search query as a URL. * @property {string|Function|undefined} createSuggestionButtonText The text to use in the button that calls createSuggestion. * @property {Function} renderControlBottom Optional controls to be rendered at the bottom of the component. */ /** * Renders a link control. A link control is a controlled input which maintains * a value associated with a link (HTML anchor element) and relevant settings * for how that link is expected to behave. * * @param {GCLinkControlProps} props Component props. */ function LinkControl( { searchInputPlaceholder, value, settings = DEFAULT_LINK_SETTINGS, onChange = noop, onRemove, noDirectEntry = false, showSuggestions = true, showInitialSuggestions, forceIsEditingLink, createSuggestion, withCreateSuggestion, inputValue: propInputValue = '', suggestionsQuery = {}, noURLSuggestion = false, createSuggestionButtonText, hasRichPreviews = false, hasTextControl = false, renderControlBottom = null, } ) { if ( withCreateSuggestion === undefined && createSuggestion ) { withCreateSuggestion = true; } const isMounting = useRef( true ); const wrapperNode = useRef(); const textInputRef = useRef(); const [ internalInputValue, setInternalInputValue ] = useState( value?.url || '' ); const [ internalTextValue, setInternalTextValue ] = useState( value?.title || '' ); const currentInputValue = propInputValue || internalInputValue; const [ isEditingLink, setIsEditingLink ] = useState( forceIsEditingLink !== undefined ? forceIsEditingLink : ! value || ! value.url ); const isEndingEditWithFocus = useRef( false ); const currentInputIsEmpty = ! currentInputValue?.trim()?.length; useEffect( () => { if ( forceIsEditingLink !== undefined && forceIsEditingLink !== isEditingLink ) { setIsEditingLink( forceIsEditingLink ); } }, [ forceIsEditingLink ] ); useEffect( () => { // We don't auto focus into the Link UI on mount // because otherwise using the keyboard to select text // *within* the link format is not possible. if ( isMounting.current ) { isMounting.current = false; return; } // Unless we are mounting, we always want to focus either: // - the URL input // - the first focusable element in the Link UI. // But in editing mode if there is a text input present then // the URL input is at index 1. If not then it is at index 0. const whichFocusTargetIndex = textInputRef?.current ? 1 : 0; // Scenario - when: // - switching between editable and non editable LinkControl // - clicking on a link // ...then move focus to the *first* element to avoid focus loss // and to ensure focus is *within* the Link UI. const nextFocusTarget = focus.focusable.find( wrapperNode.current )[ whichFocusTargetIndex ] || wrapperNode.current; nextFocusTarget.focus(); isEndingEditWithFocus.current = false; }, [ isEditingLink ] ); useEffect( () => { /** * If the value's `text` property changes then sync this * back up with state. */ if ( value?.title && value.title !== internalTextValue ) { setInternalTextValue( value.title ); } /** * Update the state value internalInputValue if the url value changes * for example when clicking on another anchor */ if ( value?.url ) { setInternalInputValue( value.url ); } }, [ value ] ); /** * Cancels editing state and marks that focus may need to be restored after * the next render, if focus was within the wrapper when editing finished. */ function stopEditing() { isEndingEditWithFocus.current = !! wrapperNode.current?.contains( wrapperNode.current.ownerDocument.activeElement ); setIsEditingLink( false ); } const { createPage, isCreatingPage, errorMessage } = useCreatePage( createSuggestion ); const handleSelectSuggestion = ( updatedValue ) => { onChange( { ...updatedValue, title: internalTextValue || updatedValue?.title, } ); stopEditing(); }; const handleSubmit = () => { if ( currentInputValue !== value?.url || internalTextValue !== value?.title ) { onChange( { url: currentInputValue, title: internalTextValue, } ); } stopEditing(); }; const handleSubmitWithEnter = ( event ) => { const { keyCode } = event; if ( keyCode === ENTER && ! currentInputIsEmpty // disallow submitting empty values. ) { event.preventDefault(); handleSubmit(); } }; const shownUnlinkControl = onRemove && value && ! isEditingLink && ! isCreatingPage; const showSettingsDrawer = !! settings?.length; // Only show text control once a URL value has been committed // and it isn't just empty whitespace. // See https://github.com/GeChiUI/gutenberg/pull/33849/#issuecomment-932194927. const showTextControl = value?.url?.trim()?.length > 0 && hasTextControl; return ( <div tabIndex={ -1 } ref={ wrapperNode } className="block-editor-link-control" > { isCreatingPage && ( <div className="block-editor-link-control__loading"> <Spinner /> { __( '正在建立…' ) }… </div> ) } { ( isEditingLink || ! value ) && ! isCreatingPage && ( <> <div className={ classnames( { 'block-editor-link-control__search-input-wrapper': true, 'has-text-control': showTextControl, } ) } > { showTextControl && ( <TextControl ref={ textInputRef } className="block-editor-link-control__field block-editor-link-control__text-content" label="Text" value={ internalTextValue } onChange={ setInternalTextValue } onKeyDown={ handleSubmitWithEnter } /> ) } <LinkControlSearchInput currentLink={ value } className="block-editor-link-control__field block-editor-link-control__search-input" placeholder={ searchInputPlaceholder } value={ currentInputValue } withCreateSuggestion={ withCreateSuggestion } onCreateSuggestion={ createPage } onChange={ setInternalInputValue } onSelect={ handleSelectSuggestion } showInitialSuggestions={ showInitialSuggestions } allowDirectEntry={ ! noDirectEntry } showSuggestions={ showSuggestions } suggestionsQuery={ suggestionsQuery } withURLSuggestion={ ! noURLSuggestion } createSuggestionButtonText={ createSuggestionButtonText } useLabel={ showTextControl } > <div className="block-editor-link-control__search-actions"> <Button onClick={ handleSubmit } label={ __( '提交' ) } icon={ keyboardReturn } className="block-editor-link-control__search-submit" disabled={ currentInputIsEmpty } // disallow submitting empty values. /> </div> </LinkControlSearchInput> </div> { errorMessage && ( <Notice className="block-editor-link-control__search-error" status="error" isDismissible={ false } > { errorMessage } </Notice> ) } </> ) } { value && ! isEditingLink && ! isCreatingPage && ( <LinkPreview key={ value?.url } // force remount when URL changes to avoid race conditions for rich previews value={ value } onEditClick={ () => setIsEditingLink( true ) } hasRichPreviews={ hasRichPreviews } hasUnlinkControl={ shownUnlinkControl } onRemove={ onRemove } /> ) } { showSettingsDrawer && ( <div className="block-editor-link-control__tools"> <LinkControlSettingsDrawer value={ value } settings={ settings } onChange={ onChange } /> </div> ) } { renderControlBottom && renderControlBottom() } </div> ); } LinkControl.ViewerFill = ViewerFill; export default LinkControl;