diff --git a/packages/block-editor/src/components/block-list/content.scss b/packages/block-editor/src/components/block-list/content.scss index a650710bfb119a..ba1714063fa8fc 100644 --- a/packages/block-editor/src/components/block-list/content.scss +++ b/packages/block-editor/src/components/block-list/content.scss @@ -89,7 +89,7 @@ _::-webkit-full-page-media, _:future, :root .has-multi-selection .block-editor-b .block-editor-block-list__block.is-highlighted, .block-editor-block-list__block.is-highlighted ~ .is-multi-selected, &.is-navigate-mode .block-editor-block-list__block.is-selected, - .block-editor-block-list__block:not([contenteditable]):focus { + .block-editor-block-list__block:not([contenteditable="true"]):focus { outline: none; // We're using a pseudo element to overflow placeholder borders diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index 51a70677a5edc7..69b04fe4c4904e 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -19,6 +19,7 @@ import { removeFormat, } from '@wordpress/rich-text'; import { Popover } from '@wordpress/components'; +import { getBlockType } from '@wordpress/blocks'; /** * Internal dependencies @@ -44,6 +45,7 @@ import FormatEdit from './format-edit'; import { getAllowedFormats } from './utils'; import { Content } from './content'; import { withDeprecations } from './with-deprecations'; +import { unlock } from '../../lock-unlock'; export const keyboardShortcutContext = createContext(); export const inputEventContext = createContext(); @@ -113,7 +115,11 @@ export function RichTextWrapper( props = removeNativeProps( props ); const anchorRef = useRef(); - const { clientId, isSelected: isBlockSelected } = useBlockEditContext(); + const { + clientId, + isSelected: isBlockSelected, + name: blockName, + } = useBlockEditContext(); const selector = ( select ) => { // Avoid subscribing to the block editor store if the block is not // selected. @@ -121,10 +127,12 @@ export function RichTextWrapper( return { isSelected: false }; } - const { getSelectionStart, getSelectionEnd } = + const { getSelectionStart, getSelectionEnd, getBlockAttributes } = select( blockEditorStore ); const selectionStart = getSelectionStart(); const selectionEnd = getSelectionEnd(); + const blockBindings = + getBlockAttributes( clientId )?.metadata?.bindings; let isSelected; @@ -137,18 +145,44 @@ export function RichTextWrapper( isSelected = selectionStart.clientId === clientId; } + // Disable Rich Text editing if block bindings specify that. + let shouldDisableEditing = false; + if ( blockBindings ) { + const blockTypeAttributes = getBlockType( blockName ).attributes; + const { getBlockBindingsSource } = unlock( + select( blockEditorStore ) + ); + for ( const [ attribute, args ] of Object.entries( + blockBindings + ) ) { + // If any of the attributes with source "rich-text" is part of the bindings, + // has a source with `lockAttributesEditing`, disable it. + if ( + blockTypeAttributes?.[ attribute ]?.source === + 'rich-text' && + getBlockBindingsSource( args.source.name ) + ?.lockAttributesEditing + ) { + shouldDisableEditing = true; + break; + } + } + } + return { selectionStart: isSelected ? selectionStart.offset : undefined, selectionEnd: isSelected ? selectionEnd.offset : undefined, isSelected, + shouldDisableEditing, }; }; - const { selectionStart, selectionEnd, isSelected } = useSelect( selector, [ - clientId, - identifier, - originalIsSelected, - isBlockSelected, - ] ); + const { selectionStart, selectionEnd, isSelected, shouldDisableEditing } = + useSelect( selector, [ + clientId, + identifier, + originalIsSelected, + isBlockSelected, + ] ); const { getSelectionStart, getSelectionEnd, getBlockRootClientId } = useSelect( blockEditorStore ); const { selectionChange } = useDispatch( blockEditorStore ); @@ -376,7 +410,7 @@ export function RichTextWrapper( useFirefoxCompat(), anchorRef, ] ) } - contentEditable={ true } + contentEditable={ ! shouldDisableEditing } suppressContentEditableWarning={ true } className={ classnames( 'block-editor-rich-text__editable', @@ -389,7 +423,11 @@ export function RichTextWrapper( // select blocks when Shift Clicking into an element with // tabIndex because Safari will focus the element. However, // Safari will correctly ignore nested contentEditable elements. - tabIndex={ props.tabIndex === 0 ? null : props.tabIndex } + tabIndex={ + props.tabIndex === 0 && ! shouldDisableEditing + ? null + : props.tabIndex + } data-wp-block-attribute-key={ identifier } /> diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js index cb0ca4e2ff3e58..5760c8edb4e2ab 100644 --- a/packages/block-editor/src/hooks/index.js +++ b/packages/block-editor/src/hooks/index.js @@ -27,6 +27,7 @@ import contentLockUI from './content-lock-ui'; import './metadata'; import blockHooks from './block-hooks'; import blockRenaming from './block-renaming'; +import './use-bindings-attributes'; createBlockEditFilter( [ diff --git a/packages/block-editor/src/hooks/use-bindings-attributes.js b/packages/block-editor/src/hooks/use-bindings-attributes.js new file mode 100644 index 00000000000000..6f669f1a321c22 --- /dev/null +++ b/packages/block-editor/src/hooks/use-bindings-attributes.js @@ -0,0 +1,148 @@ +/** + * WordPress dependencies + */ +import { getBlockType } from '@wordpress/blocks'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { useRegistry, useSelect } from '@wordpress/data'; +import { addFilter } from '@wordpress/hooks'; +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../store'; +import { useBlockEditContext } from '../components/block-edit/context'; +import { unlock } from '../lock-unlock'; + +/** @typedef {import('@wordpress/compose').WPHigherOrderComponent} WPHigherOrderComponent */ +/** @typedef {import('@wordpress/blocks').WPBlockSettings} WPBlockSettings */ + +/** + * Given a binding of block attributes, returns a higher order component that + * overrides its `attributes` and `setAttributes` props to sync any changes needed. + * + * @return {WPHigherOrderComponent} Higher-order component. + */ + +const BLOCK_BINDINGS_ALLOWED_BLOCKS = { + 'core/paragraph': [ 'content' ], + 'core/heading': [ 'content' ], + 'core/image': [ 'url', 'title', 'alt' ], + 'core/button': [ 'url', 'text' ], +}; + +const createEditFunctionWithBindingsAttribute = () => + createHigherOrderComponent( + ( BlockEdit ) => ( props ) => { + const { clientId, name: blockName } = useBlockEditContext(); + const { getBlockBindingsSource } = unlock( + useSelect( blockEditorStore ) + ); + const { getBlockAttributes, updateBlockAttributes } = + useSelect( blockEditorStore ); + + const updatedAttributes = getBlockAttributes( clientId ); + if ( updatedAttributes?.metadata?.bindings ) { + Object.entries( updatedAttributes.metadata.bindings ).forEach( + ( [ attributeName, settings ] ) => { + const source = getBlockBindingsSource( + settings.source.name + ); + + if ( source ) { + // Second argument (`updateMetaValue`) will be used to update the value in the future. + const { + placeholder, + useValue: [ metaValue = null ] = [], + } = source.useSource( + props, + settings.source.attributes + ); + + if ( placeholder && ! metaValue ) { + // If the attribute is `src` or `href`, a placeholder can't be used because it is not a valid url. + // Adding this workaround until attributes and metadata fields types are improved and include `url`. + const htmlAttribute = + getBlockType( blockName ).attributes[ + attributeName + ].attribute; + if ( + htmlAttribute === 'src' || + htmlAttribute === 'href' + ) { + updatedAttributes[ attributeName ] = null; + } else { + updatedAttributes[ attributeName ] = + placeholder; + } + } + + if ( metaValue ) { + updatedAttributes[ attributeName ] = metaValue; + } + } + } + ); + } + + const registry = useRegistry(); + + return ( + <> + + registry.batch( () => + updateBlockAttributes( blockId, newAttributes ) + ) + } + { ...props } + /> + + ); + }, + 'useBoundAttributes' + ); + +/** + * Filters a registered block's settings to enhance a block's `edit` component + * to upgrade bound attributes. + * + * @param {WPBlockSettings} settings Registered block settings. + * + * @return {WPBlockSettings} Filtered block settings. + */ +function shimAttributeSource( settings ) { + if ( ! ( settings.name in BLOCK_BINDINGS_ALLOWED_BLOCKS ) ) { + return settings; + } + settings.edit = createEditFunctionWithBindingsAttribute()( settings.edit ); + + return settings; +} + +addFilter( + 'blocks.registerBlockType', + 'core/editor/custom-sources-backwards-compatibility/shim-attribute-source', + shimAttributeSource +); + +// Add the context to all blocks. +addFilter( + 'blocks.registerBlockType', + 'core/block-bindings-ui', + ( settings, name ) => { + if ( ! ( name in BLOCK_BINDINGS_ALLOWED_BLOCKS ) ) { + return settings; + } + const contextItems = [ 'postId', 'postType', 'queryId' ]; + const usesContextArray = settings.usesContext; + const oldUsesContextArray = new Set( usesContextArray ); + contextItems.forEach( ( item ) => { + if ( ! oldUsesContextArray.has( item ) ) { + usesContextArray.push( item ); + } + } ); + settings.usesContext = usesContextArray; + return settings; + } +); diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js index a31455a0b7e7b3..aea3613884bb69 100644 --- a/packages/block-editor/src/store/private-actions.js +++ b/packages/block-editor/src/store/private-actions.js @@ -360,3 +360,13 @@ export function stopEditingAsBlocks( clientId ) { dispatch.__unstableSetTemporarilyEditingAsBlocks(); }; } + +export function registerBlockBindingsSource( source ) { + return { + type: 'REGISTER_BLOCK_BINDINGS_SOURCE', + sourceName: source.name, + sourceLabel: source.label, + useSource: source.useSource, + lockAttributesEditing: source.lockAttributesEditing, + }; +} diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index adad08c7b98dc8..caca9507f07706 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -341,3 +341,11 @@ export const getAllPatterns = createRegistrySelector( ( select ) => export function getLastFocus( state ) { return state.lastFocus; } + +export function getAllBlockBindingsSources( state ) { + return state.blockBindingsSources; +} + +export function getBlockBindingsSource( state, sourceName ) { + return state.blockBindingsSources[ sourceName ]; +} diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 70e2dc3488772d..dc69a4da609a4d 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -2023,6 +2023,20 @@ export function lastFocus( state = false, action ) { return state; } +function blockBindingsSources( state = {}, action ) { + if ( action.type === 'REGISTER_BLOCK_BINDINGS_SOURCE' ) { + return { + ...state, + [ action.sourceName ]: { + label: action.sourceLabel, + useSource: action.useSource, + lockAttributesEditing: action.lockAttributesEditing, + }, + }; + } + return state; +} + function blockPatterns( state = [], action ) { switch ( action.type ) { case 'RECEIVE_BLOCK_PATTERNS': @@ -2062,6 +2076,7 @@ const combinedReducers = combineReducers( { blockRemovalRules, openedBlockSettingsMenu, registeredInserterMediaCategories, + blockBindingsSources, blockPatterns, } ); diff --git a/packages/block-library/src/button/edit.js b/packages/block-library/src/button/edit.js index a0994ce3f84b12..f6e465786ef69e 100644 --- a/packages/block-library/src/button/edit.js +++ b/packages/block-library/src/button/edit.js @@ -9,6 +9,7 @@ import classnames from 'classnames'; import { NEW_TAB_TARGET, NOFOLLOW_REL } from './constants'; import { getUpdatedLinkAttributes } from './get-updated-link-attributes'; import removeAnchorTag from '../utils/remove-anchor-tag'; +import { unlock } from '../lock-unlock'; /** * WordPress dependencies @@ -165,6 +166,7 @@ function ButtonEdit( props ) { text, url, width, + metadata, } = attributes; const TagName = tagName || 'a'; @@ -230,6 +232,27 @@ function ButtonEdit( props ) { const useEnterRef = useEnter( { content: text, clientId } ); const mergedRef = useMergeRefs( [ useEnterRef, richTextRef ] ); + const { lockUrlControls = false } = useSelect( + ( select ) => { + if ( ! isSelected ) { + return {}; + } + + const { getBlockBindingsSource } = unlock( + select( blockEditorStore ) + ); + + return { + lockUrlControls: + !! metadata?.bindings?.url && + getBlockBindingsSource( + metadata?.bindings?.url?.source?.name + )?.lockAttributesEditing === true, + }; + }, + [ isSelected ] + ); + return ( <>
) } - { ! isURLSet && isLinkTag && ( + { ! isURLSet && isLinkTag && ! lockUrlControls && ( ) } - { isURLSet && isLinkTag && ( + { isURLSet && isLinkTag && ! lockUrlControls && ( ) } - { isLinkTag && isSelected && ( isEditingURL || isURLSet ) && ( - { - setIsEditingURL( false ); - richTextRef.current?.focus(); - } } - anchor={ popoverAnchor } - focusOnMount={ isEditingURL ? 'firstElement' : false } - __unstableSlotName={ '__unstable-block-tools-after' } - shift - > - - setAttributes( - getUpdatedLinkAttributes( { - rel, - url: newURL, - opensInNewTab: newOpensInNewTab, - nofollow: newNofollow, - } ) - ) - } - onRemove={ () => { - unlink(); + { isLinkTag && + isSelected && + ( isEditingURL || isURLSet ) && + ! metadata?.bindings?.url && ( + { + setIsEditingURL( false ); richTextRef.current?.focus(); } } - forceIsEditingLink={ isEditingURL } - settings={ LINK_SETTINGS } - /> - - ) } + anchor={ popoverAnchor } + focusOnMount={ isEditingURL ? 'firstElement' : false } + __unstableSlotName={ '__unstable-block-tools-after' } + shift + > + + setAttributes( + getUpdatedLinkAttributes( { + rel, + url: newURL, + opensInNewTab: newOpensInNewTab, + nofollow: newNofollow, + } ) + ) + } + onRemove={ () => { + unlink(); + richTextRef.current?.focus(); + } } + forceIsEditingLink={ isEditingURL } + settings={ LINK_SETTINGS } + /> + + ) } { return ( - { content } + { isUrlAttributeConnected ? ( + + { __( 'Connected to a custom field' ) } + + ) : ( + content + ) } ); }; diff --git a/packages/block-library/src/image/editor.scss b/packages/block-library/src/image/editor.scss index 934682ed91b7de..ded3768dfa7d38 100644 --- a/packages/block-library/src/image/editor.scss +++ b/packages/block-library/src/image/editor.scss @@ -27,6 +27,12 @@ opacity: 0; } } + .block-bindings-media-placeholder-message { + opacity: 0; + } + &.is-selected .block-bindings-media-placeholder-message { + opacity: 1; + } // Remove the transition while we still have a legacy placeholder style. // Otherwise the content jumps between the 1px placeholder border, and any inherited custom @@ -38,7 +44,6 @@ } } - figure.wp-block-image:not(.wp-block) { margin: 0; } diff --git a/packages/block-library/src/image/image.js b/packages/block-library/src/image/image.js index f2a484b6e03c1b..d6d259c428b6ea 100644 --- a/packages/block-library/src/image/image.js +++ b/packages/block-library/src/image/image.js @@ -124,6 +124,7 @@ export default function Image( { linkTarget, sizeSlug, lightbox, + metadata, } = attributes; // The only supported unit is px, so we can parseInt to strip the px here. @@ -411,21 +412,59 @@ export default function Image( { ); + const { + lockUrlControls = false, + lockAltControls = false, + lockTitleControls = false, + } = useSelect( + ( select ) => { + if ( ! isSelected ) { + return {}; + } + + const { getBlockBindingsSource } = unlock( + select( blockEditorStore ) + ); + const { + url: urlBinding, + alt: altBinding, + title: titleBinding, + } = metadata?.bindings || {}; + return { + lockUrlControls: + !! urlBinding && + getBlockBindingsSource( urlBinding?.source?.name ) + ?.lockAttributesEditing === true, + lockAltControls: + !! altBinding && + getBlockBindingsSource( altBinding?.source?.name ) + ?.lockAttributesEditing === true, + lockTitleControls: + !! titleBinding && + getBlockBindingsSource( titleBinding?.source?.name ) + ?.lockAttributesEditing === true, + }; + }, + [ isSelected ] + ); + const controls = ( <> - { ! multiImageSelection && ! isEditingImage && ( - - ) } + { ! multiImageSelection && + ! isEditingImage && + ! lockUrlControls && ( + + ) } { allowCrop && ( setIsEditingImage( true ) } @@ -441,19 +480,21 @@ export default function Image( { /> ) } - { ! multiImageSelection && ! isEditingImage && ( - - - - ) } + { ! multiImageSelection && + ! isEditingImage && + ! lockUrlControls && ( + + + + ) } { ! multiImageSelection && externalBlob && ( @@ -484,16 +525,27 @@ export default function Image( { label={ __( 'Alternative text' ) } value={ alt || '' } onChange={ updateAlt } + disabled={ lockAltControls } help={ - <> - + lockAltControls ? ( + <> + { __( + 'Connected to a custom field' + ) } + + ) : ( + <> + + { __( + 'Describe the purpose of the image.' + ) } + +
{ __( - 'Describe the purpose of the image.' + 'Leave empty if decorative.' ) } -
-
- { __( 'Leave empty if decorative.' ) } - + + ) } __nextHasNoMarginBottom /> @@ -543,17 +595,22 @@ export default function Image( { label={ __( 'Title attribute' ) } value={ title || '' } onChange={ onSetTitle } + disabled={ lockTitleControls } help={ - <> - { __( - 'Describe the role of this image on the page.' - ) } - + lockTitleControls ? ( + <>{ __( 'Connected to a custom field' ) } + ) : ( + <> { __( - '(Note: many devices and browsers do not display this text.)' + 'Describe the role of this image on the page.' ) } - - + + { __( + '(Note: many devices and browsers do not display this text.)' + ) } + + + ) } /> @@ -748,7 +805,8 @@ export default function Image( { } if ( ! url && ! temporaryURL ) { - return sizeControls; + // Add all controls if the image attributes are connected. + return metadata?.bindings ? controls : sizeControls; } return ( diff --git a/packages/editor/package.json b/packages/editor/package.json index b974c7443851f1..63c81cbd5cc7f8 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -27,7 +27,7 @@ "sideEffects": [ "build-style/**", "src/**/*.scss", - "{src,build,build-module}/{index.js,store/index.js,hooks/**}" + "{src,build,build-module}/{index.js,store/index.js,hooks/**,bindings/**}" ], "dependencies": { "@babel/runtime": "^7.16.0", diff --git a/packages/editor/src/bindings/index.js b/packages/editor/src/bindings/index.js new file mode 100644 index 00000000000000..8a883e8904a71b --- /dev/null +++ b/packages/editor/src/bindings/index.js @@ -0,0 +1,13 @@ +/** + * WordPress dependencies + */ +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { dispatch } from '@wordpress/data'; +/** + * Internal dependencies + */ +import { unlock } from '../lock-unlock'; +import postMeta from './post-meta'; + +const { registerBlockBindingsSource } = unlock( dispatch( blockEditorStore ) ); +registerBlockBindingsSource( postMeta ); diff --git a/packages/editor/src/bindings/post-meta.js b/packages/editor/src/bindings/post-meta.js new file mode 100644 index 00000000000000..17f5e1837e35e0 --- /dev/null +++ b/packages/editor/src/bindings/post-meta.js @@ -0,0 +1,42 @@ +/** + * WordPress dependencies + */ +import { useEntityProp } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import { store as editorStore } from '../store'; + +export default { + name: 'post_meta', + label: __( 'Post Meta' ), + useSource( props, sourceAttributes ) { + const { getCurrentPostType } = useSelect( editorStore ); + const { context } = props; + const { value: metaKey } = sourceAttributes; + const postType = context.postType + ? context.postType + : getCurrentPostType(); + const [ meta, setMeta ] = useEntityProp( + 'postType', + context.postType, + 'meta', + context.postId + ); + + if ( postType === 'wp_template' ) { + return { placeholder: metaKey }; + } + const metaValue = meta[ metaKey ]; + const updateMetaValue = ( newValue ) => { + setMeta( { ...meta, [ metaKey ]: newValue } ); + }; + return { + placeholder: metaKey, + useValue: [ metaValue, updateMetaValue ], + }; + }, + lockAttributesEditing: true, +}; diff --git a/packages/editor/src/index.js b/packages/editor/src/index.js index 05c04b8232907c..3f6d7a78d837c0 100644 --- a/packages/editor/src/index.js +++ b/packages/editor/src/index.js @@ -1,6 +1,7 @@ /** * Internal dependencies */ +import './bindings'; import './hooks'; export { storeConfig, store } from './store';