diff --git a/docs/manifest.json b/docs/manifest.json index cf30aff19c503b..3eaf97c31ef344 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1397,6 +1397,12 @@ "markdown_source": "../packages/base-styles/README.md", "parent": "packages" }, + { + "title": "@wordpress/bindings", + "slug": "packages-bindings", + "markdown_source": "../packages/bindings/README.md", + "parent": "packages" + }, { "title": "@wordpress/blob", "slug": "packages-blob", diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md index 486fcddfe04ac6..16954b90e2479d 100644 --- a/docs/reference-guides/data/data-core-block-editor.md +++ b/docs/reference-guides/data/data-core-block-editor.md @@ -1555,6 +1555,36 @@ _Returns_ - `Object`: Action object. +### resetBindingBlocks + +Creates an action to register a block's attribute binding to an external property. + +It registers a specific block attribute to be updated based on the value of an external property, identified by a unique key. + +_Parameters_ + +- _clientId_ `string`: - Block client ID. +- _attribute_ `string`: - The name of the block attribute to bind. +- _key_ `string`: - The key representing the external property. + +_Returns_ + +- `Object`: Redux 'RESET_BINDING_CONNECTION_BLOCKS' type action. + +### resetBlockBindingConnections + +Connect blocks with bound attributes to external data sources. + +Attributes specified in block metadata bindings are updated according to the corresponding external values. + +_Parameters_ + +- _blocks_ `Object`: - Blocks list. + +_Returns_ + +- `Function`: Returns a Redux thunk function that processes blocks and sets up bindings. + ### resetBlocks Action that resets blocks state to the specified array of blocks, taking precedence over any other content reflected as an edit in state. @@ -1796,6 +1826,10 @@ _Returns_ - `Object`: Action object. +### unregisterBlockBinding + +Undocumented declaration. + ### unsetBlockEditingMode Clears the block editing mode for a given block. @@ -1839,6 +1873,10 @@ _Returns_ - `Object`: Action object. +### updateBlockBoundAttributes + +Undocumented declaration. + ### updateBlockListSettings Action that changes the nested settings of a given block. diff --git a/package-lock.json b/package-lock.json index 5a23420f5d8884..9f78e9a127ed78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@wordpress/annotations": "file:packages/annotations", "@wordpress/api-fetch": "file:packages/api-fetch", "@wordpress/autop": "file:packages/autop", + "@wordpress/bindings": "file:packages/bindings", "@wordpress/blob": "file:packages/blob", "@wordpress/block-directory": "file:packages/block-directory", "@wordpress/block-editor": "file:packages/block-editor", @@ -19131,6 +19132,10 @@ "resolved": "packages/base-styles", "link": true }, + "node_modules/@wordpress/bindings": { + "resolved": "packages/bindings", + "link": true + }, "node_modules/@wordpress/blob": { "resolved": "packages/blob", "link": true @@ -54785,6 +54790,22 @@ "dev": true, "license": "GPL-2.0-or-later" }, + "packages/bindings": { + "version": "0.23.0", + "license": "GPL-2.0-or-later", + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/data": "file:../data", + "@wordpress/private-apis": "file:../private-apis" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "packages/blob": { "name": "@wordpress/blob", "version": "3.54.0", @@ -54842,6 +54863,7 @@ "@react-spring/web": "^9.4.5", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", + "@wordpress/bindings": "file:../bindings", "@wordpress/blob": "file:../blob", "@wordpress/blocks": "file:../blocks", "@wordpress/commands": "file:../commands", @@ -54936,6 +54958,7 @@ "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/autop": "file:../autop", + "@wordpress/bindings": "file:../bindings", "@wordpress/blob": "file:../blob", "@wordpress/block-editor": "file:../block-editor", "@wordpress/blocks": "file:../blocks", @@ -55819,6 +55842,7 @@ "@babel/runtime": "^7.16.0", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", + "@wordpress/bindings": "file:../bindings", "@wordpress/blob": "file:../blob", "@wordpress/block-editor": "file:../block-editor", "@wordpress/blocks": "file:../blocks", @@ -70826,6 +70850,14 @@ "@wordpress/base-styles": { "version": "file:packages/base-styles" }, + "@wordpress/bindings": { + "version": "file:packages/bindings", + "requires": { + "@babel/runtime": "^7.16.0", + "@wordpress/data": "file:../data", + "@wordpress/private-apis": "file:../private-apis" + } + }, "@wordpress/blob": { "version": "file:packages/blob", "requires": { @@ -70867,6 +70899,7 @@ "@react-spring/web": "^9.4.5", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", + "@wordpress/bindings": "file:../bindings", "@wordpress/blob": "file:../blob", "@wordpress/blocks": "file:../blocks", "@wordpress/commands": "file:../commands", @@ -70934,6 +70967,7 @@ "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/autop": "file:../autop", + "@wordpress/bindings": "file:../bindings", "@wordpress/blob": "file:../blob", "@wordpress/block-editor": "file:../block-editor", "@wordpress/blocks": "file:../blocks", @@ -71575,6 +71609,7 @@ "@babel/runtime": "^7.16.0", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", + "@wordpress/bindings": "file:../bindings", "@wordpress/blob": "file:../blob", "@wordpress/block-editor": "file:../block-editor", "@wordpress/blocks": "file:../blocks", diff --git a/package.json b/package.json index 5c63beee49d2a1..9968536240074f 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@wordpress/annotations": "file:packages/annotations", "@wordpress/api-fetch": "file:packages/api-fetch", "@wordpress/autop": "file:packages/autop", + "@wordpress/bindings": "file:packages/bindings", "@wordpress/blob": "file:packages/blob", "@wordpress/block-directory": "file:packages/block-directory", "@wordpress/block-editor": "file:packages/block-editor", diff --git a/packages/bindings/.npmrc b/packages/bindings/.npmrc new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/bindings/CHANGELOG.md b/packages/bindings/CHANGELOG.md new file mode 100644 index 00000000000000..bdf416b94104e3 --- /dev/null +++ b/packages/bindings/CHANGELOG.md @@ -0,0 +1,3 @@ + + +Initial release. diff --git a/packages/bindings/README.md b/packages/bindings/README.md new file mode 100644 index 00000000000000..b5ef08d96becd7 --- /dev/null +++ b/packages/bindings/README.md @@ -0,0 +1 @@ +# Bindings API diff --git a/packages/bindings/package.json b/packages/bindings/package.json new file mode 100644 index 00000000000000..430f4a3651ea51 --- /dev/null +++ b/packages/bindings/package.json @@ -0,0 +1,39 @@ +{ + "name": "@wordpress/bindings", + "version": "0.23.0", + "description": "Connect to external sources by using the Bindings API.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "bindings" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/bindings/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/bindings" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=12" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/data": "file:../data", + "@wordpress/private-apis": "file:../private-apis" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/bindings/src/index.js b/packages/bindings/src/index.js new file mode 100644 index 00000000000000..33b78d5a1d117e --- /dev/null +++ b/packages/bindings/src/index.js @@ -0,0 +1 @@ +export { store } from './store'; diff --git a/packages/bindings/src/lock-unlock.js b/packages/bindings/src/lock-unlock.js new file mode 100644 index 00000000000000..e11bd687d87421 --- /dev/null +++ b/packages/bindings/src/lock-unlock.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; + +export const { lock, unlock } = + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I know using unstable features means my theme or plugin will inevitably break in the next version of WordPress.', + '@wordpress/commands' + ); diff --git a/packages/bindings/src/store/index.js b/packages/bindings/src/store/index.js new file mode 100644 index 00000000000000..adf76c89d62655 --- /dev/null +++ b/packages/bindings/src/store/index.js @@ -0,0 +1,37 @@ +/** + * WordPress dependencies + */ +import { createReduxStore, register } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as privateActions from './private-actions'; +import * as privateSelectors from './private-selectors'; +import { unlock } from '../lock-unlock'; + +const STORE_NAME = 'core/bindings'; + +/** + * Store definition for the bindings namespace. + * + * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#createReduxStore + * + * @type {Object} + * + * @example + * ```js + * import { store as bindingsStore } from '@wordpress/bindings'; + * import { dispatch } from '@wordpress/data'; + * + * const { registerBindingsSource } = dispatch( bindingsStore ); + * ``` + */ +export const store = createReduxStore( STORE_NAME, { + reducer, +} ); + +register( store ); +unlock( store ).registerPrivateActions( privateActions ); +unlock( store ).registerPrivateSelectors( privateSelectors ); diff --git a/packages/bindings/src/store/private-actions.js b/packages/bindings/src/store/private-actions.js new file mode 100644 index 00000000000000..72cf1a3229a3b1 --- /dev/null +++ b/packages/bindings/src/store/private-actions.js @@ -0,0 +1,107 @@ +/** + * Internal dependencies + */ +import { generateBindingsConnectionKey } from './utils'; + +/** + * Register an external source + * + * @param {string} source Name of the source to register. + */ +export function registerBindingsSource( source ) { + return { + type: 'REGISTER_BINDINGS_SOURCE', + ...source, + }; +} + +/** + * Sets up a subscription to monitor changes + * in a specified bindings connection. + * + * @param {string} key - The key identifying the bindings connection to observe. + * @param {Function} [updateCallback] - Optional callback invoked when the observed property changes. + * @return {Function} Thunk. + */ +export function observeBindingsConnectionChange( key, updateCallback ) { + return ( { select, registry, dispatch } ) => { + const handler = select.getBindingsConnectionHandler( key ); + let currentValue = handler.get(); + + function watchValueChanges() { + const value = select.getBindingsConnectionValue( key ); + if ( value === currentValue ) { + return; + } + + currentValue = value; + updateCallback?.( value ); + } + + dispatch( { + type: 'UPDATE_BINDINGS_CONNECTION', + key, + unsubscribe: registry.subscribe( watchValueChanges ), + } ); + }; +} + +/** + * Registers a connection to a bindings source. + * The connection is established by a combination + * of the source handler name and the binding arguments. + * + * @param {Object} settings - Settings. + * @param {string} settings.source - The source handler name. + * @param {Object} settings.args - The binding arguments. + * @param {Function} updateCallback - Callback invoked when the connection value changes. + * @return {Function} Thunk function for Redux dispatch, registers the connection. + */ +export function registerBindingsConnection( { source, args }, updateCallback ) { + return ( { dispatch, select } ) => { + const settings = select.getBindingsSource( source ); + if ( ! settings ) { + return; + } + + // Do not register if it's already registered. + const key = generateBindingsConnectionKey( { source, args } ); + if ( select.getBindingsConnectionHandler( key ) ) { + return; + } + + const { connect } = settings; + const handler = connect( args ); + + dispatch( { + type: 'REGISTER_BINDINGS_CONNECTION', + key, + ...handler, + } ); + + /* + * Observe the external property to monitor changes. + * To do: scale this to register multiple callbacks. + */ + dispatch.observeBindingsConnectionChange( key, updateCallback ); + }; +} + +/** + * Updates the value of a bindings connection. + * The connection is identified by the connection key. + * + * @param {string} key - The connection key. + * @param {*} value - The new value. + * @return {Function} Thunk function for Redux dispatch, updates the connection value. + */ +export function updateBindingsConnectionValue( key, value ) { + return ( { select } ) => { + const bindingsConnection = select.getBindingsConnectionHandler( key ); + if ( ! bindingsConnection ) { + return; + } + + bindingsConnection.update( value ); + }; +} diff --git a/packages/bindings/src/store/private-selectors.js b/packages/bindings/src/store/private-selectors.js new file mode 100644 index 00000000000000..193d2b9e45684d --- /dev/null +++ b/packages/bindings/src/store/private-selectors.js @@ -0,0 +1,63 @@ +/** + * Internal dependencies + */ +import { generateBindingsConnectionKey } from './utils'; + +/** + * Returns all the bindings sources registered. + * + * @param {Object} state - Data state. + * @return {Object} All registered sources handlers. + */ +export function getAllBindingsSources( state ) { + return state.sources; +} + +/** + * Returns a specific bindings source. + * + * @param {Object} state - Data state. + * @param {string} sourceName - Source handler name. + * @return {Object} The specific binding source. + */ +export function getBindingsSource( state, sourceName ) { + return state.sources[ sourceName ]; +} + +/** + * Return the bindings connection key, + * based on the source handler name and the binding arguments. + * + * @param {Object} state - Data state. + * @param {Object} settings - Settings. + * @param {string} settings.source - The source handler name. + * @param {Object} settings.args - The binding arguments. + * @return {string|undefined} The generated key. + */ +export function getBindingsConnectionKey( state, settings ) { + return generateBindingsConnectionKey( settings ); +} + +/** + * Return the bindings connection handler, + * based on the connection key. + * + * @param {Object} state - Data state. + * @param {string} key - The connection key. + * @return {Object} The connection handler. + */ +export function getBindingsConnectionHandler( state, key ) { + return state.connections?.[ key ]; +} + +/** + * Return the bindings connection value, + * based on the connection key. + * + * @param {Object} state - Data state. + * @param {string} key - The connection key. + * @return {*} The connection value. + */ +export function getBindingsConnectionValue( state, key ) { + return getBindingsConnectionHandler( state, key )?.get(); +} diff --git a/packages/bindings/src/store/reducer.js b/packages/bindings/src/store/reducer.js new file mode 100644 index 00000000000000..608bc7c1e30f3c --- /dev/null +++ b/packages/bindings/src/store/reducer.js @@ -0,0 +1,53 @@ +/** + * WordPress dependencies + */ +import { combineReducers } from '@wordpress/data'; +/** + * Internal dependencies + */ + +export function sources( state = {}, action ) { + if ( action.type === 'REGISTER_BINDINGS_SOURCE' ) { + const source = { ...action }; + delete source.type; + delete source.name; + + return { + ...state, + [ action.name ]: source, + }; + } + return state; +} + +export function connections( state = {}, action ) { + switch ( action.type ) { + case 'REGISTER_BINDINGS_CONNECTION': { + const { key, type, ...rest } = action; + return { + ...state, + [ key ]: { ...rest }, + }; + } + + case 'UPDATE_BINDINGS_CONNECTION': { + const { type, key, ...updates } = action; + return { + ...state, + [ key ]: { + ...state[ key ], + ...updates, + }, + }; + } + } + + return state; +} + +const reducer = combineReducers( { + sources, + connections, +} ); + +export default reducer; diff --git a/packages/bindings/src/store/utils.js b/packages/bindings/src/store/utils.js new file mode 100644 index 00000000000000..64db5a9cdc00d9 --- /dev/null +++ b/packages/bindings/src/store/utils.js @@ -0,0 +1,16 @@ +/** + * Generates a key for a bindings connection, + * based on the source handler name and the binding arguments. + * + * @param {Object} settings - Settings. + * @param {string} settings.source - The source handler name. + * @param {Object} settings.args - The binding arguments. + * @return {string|undefined} The generated key. + */ +export function generateBindingsConnectionKey( { source, args } = {} ) { + if ( ! source?.length || ! args ) { + return; + } + + return `${ source }|${ JSON.stringify( args ).replace( /{|}|"/g, '' ) }`; +} diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index 498eee0c936017..b0fe8dea729397 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -37,6 +37,7 @@ "@react-spring/web": "^9.4.5", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", + "@wordpress/bindings": "file:../bindings", "@wordpress/blob": "file:../blob", "@wordpress/blocks": "file:../blocks", "@wordpress/commands": "file:../commands", diff --git a/packages/block-editor/src/components/block-list/use-block-props/index.js b/packages/block-editor/src/components/block-list/use-block-props/index.js index c929c1014dc030..0df7170539d51e 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/index.js +++ b/packages/block-editor/src/components/block-list/use-block-props/index.js @@ -29,7 +29,7 @@ import { useNavModeExit } from './use-nav-mode-exit'; import { useBlockRefProvider } from './use-block-refs'; import { useIntersectionObserver } from './use-intersection-observer'; import { useFlashEditableBlocks } from '../../use-flash-editable-blocks'; -import { canBindBlock } from '../../../hooks/use-bindings-attributes'; +import { canBindBlock } from '../../../../../editor/src/bindings/utils'; /** * This hook is used to lightly mark an element as a block element. The element diff --git a/packages/block-editor/src/components/block-toolbar/index.js b/packages/block-editor/src/components/block-toolbar/index.js index 8d28bd001670fc..b64c7cdfbc1a8a 100644 --- a/packages/block-editor/src/components/block-toolbar/index.js +++ b/packages/block-editor/src/components/block-toolbar/index.js @@ -37,7 +37,7 @@ import NavigableToolbar from '../navigable-toolbar'; import Shuffle from './shuffle'; import BlockBindingsIndicator from '../block-bindings-toolbar-indicator'; import { useHasBlockToolbar } from './use-has-block-toolbar'; -import { canBindBlock } from '../../hooks/use-bindings-attributes'; +import { canBindBlock } from '../../../../editor/src/bindings/utils'; /** * Renders the block toolbar. * diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index 3d1725c54fe715..65ec7e6be36cf5 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -3,6 +3,11 @@ */ import classnames from 'classnames'; +/** + * WordPress dependencies + */ +import { store as bindingsStore } from '@wordpress/bindings'; + /** * WordPress dependencies */ @@ -19,7 +24,7 @@ import { removeFormat, } from '@wordpress/rich-text'; import { Popover } from '@wordpress/components'; -import { getBlockType, store as blocksStore } from '@wordpress/blocks'; +import { getBlockType } from '@wordpress/blocks'; /** * Internal dependencies @@ -47,7 +52,7 @@ import { getAllowedFormats } from './utils'; import { Content } from './content'; import { withDeprecations } from './with-deprecations'; import { unlock } from '../../lock-unlock'; -import { canBindBlock } from '../../hooks/use-bindings-attributes'; +import { canBindBlock } from '../../../../editor/src/bindings/utils'; export const keyboardShortcutContext = createContext(); export const inputEventContext = createContext(); @@ -164,9 +169,7 @@ export function RichTextWrapper( if ( blockBindings && canBindBlock( blockName ) ) { const blockTypeAttributes = getBlockType( blockName ).attributes; - const { getBlockBindingsSource } = unlock( - select( blocksStore ) - ); + const { getBindingsSource } = unlock( select( bindingsStore ) ); for ( const [ attribute, args ] of Object.entries( blockBindings ) ) { @@ -178,9 +181,10 @@ export function RichTextWrapper( } // If the source is not defined, or if its value of `lockAttributesEditing` is `true`, disable it. - const blockBindingsSource = getBlockBindingsSource( + const blockBindingsSource = getBindingsSource( args.source ); + if ( ! blockBindingsSource || blockBindingsSource.lockAttributesEditing @@ -329,7 +333,7 @@ export function RichTextWrapper( onChange, } ); - useMarkPersistent( { html: adjustedValue, value } ); + useMarkPersistent( value ); const keyboardShortcuts = useRef( new Set() ); const inputEvents = useRef( new Set() ); diff --git a/packages/block-editor/src/components/rich-text/use-mark-persistent.js b/packages/block-editor/src/components/rich-text/use-mark-persistent.js index 10e157452fbe22..e52df535a84161 100644 --- a/packages/block-editor/src/components/rich-text/use-mark-persistent.js +++ b/packages/block-editor/src/components/rich-text/use-mark-persistent.js @@ -9,7 +9,7 @@ import { useDispatch } from '@wordpress/data'; */ import { store as blockEditorStore } from '../../store'; -export function useMarkPersistent( { html, value } ) { +export function useMarkPersistent( value ) { const previousText = useRef(); const hasActiveFormats = !! value.activeFormats?.length; const { __unstableMarkLastChangeAsPersistent } = @@ -36,5 +36,5 @@ export function useMarkPersistent( { html, value } ) { } __unstableMarkLastChangeAsPersistent(); - }, [ html, hasActiveFormats ] ); + }, [ value.text, hasActiveFormats ] ); } diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js index 5773cd7c34595b..95809106cb2f63 100644 --- a/packages/block-editor/src/hooks/index.js +++ b/packages/block-editor/src/hooks/index.js @@ -29,7 +29,6 @@ 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 deleted file mode 100644 index 5cd8cb46b3b7e7..00000000000000 --- a/packages/block-editor/src/hooks/use-bindings-attributes.js +++ /dev/null @@ -1,264 +0,0 @@ -/** - * WordPress dependencies - */ -import { getBlockType, store as blocksStore } from '@wordpress/blocks'; -import { createHigherOrderComponent } from '@wordpress/compose'; -import { useSelect } from '@wordpress/data'; -import { useLayoutEffect, useCallback, useState } from '@wordpress/element'; -import { addFilter } from '@wordpress/hooks'; -import { RichTextData } from '@wordpress/rich-text'; - -/** - * Internal dependencies - */ -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', 'linkTarget' ], -}; - -/** - * Based on the given block name, - * check if it is possible to bind the block. - * - * @param {string} blockName - The block name. - * @return {boolean} Whether it is possible to bind the block to sources. - */ -export function canBindBlock( blockName ) { - return blockName in BLOCK_BINDINGS_ALLOWED_BLOCKS; -} - -/** - * Based on the given block name and attribute name, - * check if it is possible to bind the block attribute. - * - * @param {string} blockName - The block name. - * @param {string} attributeName - The attribute name. - * @return {boolean} Whether it is possible to bind the block attribute. - */ -export function canBindAttribute( blockName, attributeName ) { - return ( - canBindBlock( blockName ) && - BLOCK_BINDINGS_ALLOWED_BLOCKS[ blockName ].includes( attributeName ) - ); -} - -/** - * This component is responsible for detecting and - * propagating data changes from the source to the block. - * - * @param {Object} props - The component props. - * @param {string} props.attrName - The attribute name. - * @param {Object} props.blockProps - The block props with bound attribute. - * @param {Object} props.source - Source handler. - * @param {Object} props.args - The arguments to pass to the source. - * @param {Function} props.onPropValueChange - The function to call when the attribute value changes. - * @return {null} Data-handling component. Render nothing. - */ -const BindingConnector = ( { - args, - attrName, - blockProps, - source, - onPropValueChange, -} ) => { - const { placeholder, value: propValue } = source.useSource( - blockProps, - args - ); - - const { name: blockName } = blockProps; - const attrValue = blockProps.attributes[ attrName ]; - - const updateBoundAttibute = useCallback( - ( newAttrValue, prevAttrValue ) => { - /* - * If the attribute is a RichTextData instance, - * (core/paragraph, core/heading, core/button, etc.) - * compare its HTML representation with the new value. - * - * To do: it looks like a workaround. - * Consider improving the attribute and metadata fields types. - */ - if ( prevAttrValue instanceof RichTextData ) { - // Bail early if the Rich Text value is the same. - if ( prevAttrValue.toHTMLString() === newAttrValue ) { - return; - } - - /* - * To preserve the value type, - * convert the new value to a RichTextData instance. - */ - newAttrValue = RichTextData.fromHTMLString( newAttrValue ); - } - - if ( prevAttrValue === newAttrValue ) { - return; - } - - onPropValueChange( { [ attrName ]: newAttrValue } ); - }, - [ attrName, onPropValueChange ] - ); - - useLayoutEffect( () => { - if ( typeof propValue !== 'undefined' ) { - updateBoundAttibute( propValue, attrValue ); - } else if ( placeholder ) { - /* - * Placeholder fallback. - * 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[ attrName ].attribute; - - if ( htmlAttribute === 'src' || htmlAttribute === 'href' ) { - updateBoundAttibute( null ); - return; - } - - updateBoundAttibute( placeholder ); - } - }, [ - updateBoundAttibute, - propValue, - attrValue, - placeholder, - blockName, - attrName, - ] ); - - return null; -}; - -/** - * BlockBindingBridge acts like a component wrapper - * that connects the bound attributes of a block - * to the source handlers. - * For this, it creates a BindingConnector for each bound attribute. - * - * @param {Object} props - The component props. - * @param {Object} props.blockProps - The BlockEdit props object. - * @param {Object} props.bindings - The block bindings settings. - * @param {Function} props.onPropValueChange - The function to call when the attribute value changes. - * @return {null} Data-handling component. Render nothing. - */ -function BlockBindingBridge( { blockProps, bindings, onPropValueChange } ) { - const blockBindingsSources = unlock( - useSelect( blocksStore ) - ).getAllBlockBindingsSources(); - - return ( - <> - { Object.entries( bindings ).map( - ( [ attrName, boundAttribute ] ) => { - // Bail early if the block doesn't have a valid source handler. - const source = - blockBindingsSources[ boundAttribute.source ]; - if ( ! source?.useSource ) { - return null; - } - - return ( - - ); - } - ) } - - ); -} - -const withBlockBindingSupport = createHigherOrderComponent( - ( BlockEdit ) => ( props ) => { - /* - * Collect and update the bound attributes - * in a separate state. - */ - const [ boundAttributes, setBoundAttributes ] = useState( {} ); - const updateBoundAttributes = useCallback( - ( newAttributes ) => - setBoundAttributes( ( prev ) => ( { - ...prev, - ...newAttributes, - } ) ), - [] - ); - - /* - * Create binding object filtering - * only the attributes that can be bound. - */ - const bindings = Object.fromEntries( - Object.entries( props.attributes.metadata?.bindings || {} ).filter( - ( [ attrName ] ) => canBindAttribute( props.name, attrName ) - ) - ); - - return ( - <> - { Object.keys( bindings ).length > 0 && ( - - ) } - - - - ); - }, - 'withBlockBindingSupport' -); - -/** - * Filters a registered block's settings to enhance a block's `edit` component - * to upgrade bound attributes. - * - * @param {WPBlockSettings} settings - Registered block settings. - * @param {string} name - Block name. - * @return {WPBlockSettings} Filtered block settings. - */ -function shimAttributeSource( settings, name ) { - if ( ! canBindBlock( name ) ) { - return settings; - } - - return { - ...settings, - edit: withBlockBindingSupport( settings.edit ), - }; -} - -addFilter( - 'blocks.registerBlockType', - 'core/editor/custom-sources-backwards-compatibility/shim-attribute-source', - shimAttributeSource -); diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index e9281727804f1c..643f7562fc3363 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -15,6 +15,7 @@ import { getBlockSupport, isUnmodifiedDefaultBlock, } from '@wordpress/blocks'; +import { store as bindingsStore } from '@wordpress/bindings'; import { speak } from '@wordpress/a11y'; import { __, _n, sprintf } from '@wordpress/i18n'; import { create, insert, remove, toHTMLString } from '@wordpress/rich-text'; @@ -31,6 +32,7 @@ import { __experimentalUpdateSettings, privateRemoveBlocks, } from './private-actions'; +import { unlock } from '../lock-unlock'; /** @typedef {import('../components/use-on-block-drop/types').WPDropOperation} WPDropOperation */ @@ -47,6 +49,7 @@ export const resetBlocks = ( blocks ) => ( { dispatch } ) => { dispatch( { type: 'RESET_BLOCKS', blocks } ); + dispatch( resetBlockBindingConnections( blocks ) ); dispatch( validateBlocksToTemplate( blocks ) ); }; @@ -148,6 +151,60 @@ export function receiveBlocks( blocks ) { }; } +export function updateBlockBoundAttributes( + clientIds, + attributes, + uniqueByBlock = false +) { + clientIds = castArray( clientIds ); + + return ( { dispatch, select, registry } ) => { + for ( const clientId of clientIds ) { + for ( const [ attribute, value ] of Object.entries( attributes ) ) { + /* + * Get the external property key bound to the attribute, + * based on the block client ID and attribute name. + */ + const propertyKey = select.getBindingsConnectionKey( + clientId, + attribute + ); + + /* + * Get all blocks that have an attribute + * bound to the same external property. + */ + const blocksWithBoundAttributes = + select.getBlocksByBindingsConnectionKey( + propertyKey, + value + ); + + if ( ! blocksWithBoundAttributes?.length ) { + continue; + } + + /* + * Update all blocks that have + * an attribute bound to the same external property. + */ + blocksWithBoundAttributes.forEach( ( subAction ) => { + registry.batch( () => { + dispatch.syncDerivedUpdates( () => { + dispatch( { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientIds: subAction.clientIds, + attributes: subAction.attributes, + uniqueByBlock, + } ); + } ); + } ); + } ); + } + } + }; +} + /** * Action that updates attributes of multiple blocks with the specified client IDs. * @@ -162,11 +219,170 @@ export function updateBlockAttributes( attributes, uniqueByBlock = false ) { + clientIds = castArray( clientIds ); + + return ( { dispatch, select, registry } ) => { + dispatch( { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientIds, + attributes, + uniqueByBlock, + } ); + + const { updateBindingsConnectionValue } = unlock( + registry.dispatch( bindingsStore ) + ); + + registry.batch( () => { + for ( const clientId of clientIds ) { + for ( const [ attribute, value ] of Object.entries( + attributes + ) ) { + // Pick the external property key bound to the attribute. + const key = select.getBindingsConnectionKey( + clientId, + attribute + ); + + /* + * Update the external property with the new value. + */ + updateBindingsConnectionValue( key, value ); + } + } + } ); + }; +} + +/** + * Connect blocks with bound attributes to external data sources. + * + * Attributes specified in block metadata bindings + * are updated according to the corresponding external values. + * + * @param {Object} blocks - Blocks list. + * @return {Function} Returns a Redux thunk function that processes blocks and sets up bindings. + */ +export function resetBlockBindingConnections( blocks ) { + return ( { dispatch, registry, select } ) => { + const clientIdsWithBoundAttributes = + select.getBlocksWithBoundAttributes( blocks ); + + if ( ! clientIdsWithBoundAttributes ) { + return; + } + + const { registerBindingsConnection } = unlock( + registry.dispatch( bindingsStore ) + ); + + const { getBindingsConnectionKey } = unlock( + registry.select( bindingsStore ) + ); + + registry.batch( () => { + Object.entries( clientIdsWithBoundAttributes ).forEach( + ( [ clientId, attributes ] ) => { + Object.entries( attributes ).forEach( + ( [ attribute, bindSettings ] ) => { + /* + * Register the external property handler, + * and pass a callback function + * to update the value of the bound attribute. + */ + registerBindingsConnection( + bindSettings, + ( newValue ) => { + /* + * [binding-on-sync]: Update bound attribute value. + * + * Update the block attribute + * when the external property value changes. + */ + dispatch.syncDerivedUpdates( () => { + dispatch.updateBlockBoundAttributes( + clientId, + { + [ attribute ]: newValue, + } + ); + } ); + } + ); + + // Pick the external property key bound to the attribute. + const bindPropertyKey = + getBindingsConnectionKey( bindSettings ); + + dispatch.syncDerivedUpdates( () => { + dispatch.resetBindingBlocks( + clientId, + attribute, + bindPropertyKey + ); + } ); + + /* + * [binding-on-sync]: First bound attribute value update. + */ + + const currentAttributeValue = + select.getBlockAttributes( clientId )?.[ + attribute + ]; + + const boundValue = select.getBoundAttributeValue( + bindPropertyKey, + currentAttributeValue + ); + + /* + * Sync the block attribute with the external property value. + */ + if ( currentAttributeValue !== boundValue ) { + dispatch.syncDerivedUpdates( () => { + dispatch( { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientIds: [ clientId ], + attributes: { + [ attribute ]: boundValue, + }, + uniqueByBlock: false, + } ); + } ); + } + } + ); + } + ); + } ); + }; +} + +/** + * Creates an action to register a block's attribute binding to an external property. + * + * It registers a specific block attribute to be updated based on the value of an external property, + * identified by a unique key. + * + * @param {string} clientId - Block client ID. + * @param {string} attribute - The name of the block attribute to bind. + * @param {string} key - The key representing the external property. + * @return {Object} Redux 'RESET_BINDING_CONNECTION_BLOCKS' type action. + */ +export function resetBindingBlocks( clientId, attribute, key ) { + return { + type: 'RESET_BINDING_CONNECTION_BLOCKS', + clientId, + attribute, + key, + }; +} +export function unregisterBlockBinding( clientId, attribute ) { return { - type: 'UPDATE_BLOCK_ATTRIBUTES', - clientIds: castArray( clientIds ), - attributes, - uniqueByBlock, + type: 'UNRESET_BINDING_CONNECTION_BLOCKS', + clientId, + attribute, }; } diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index e4885cbbd9e1e1..0398e1cab65d7a 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -7,6 +7,7 @@ import createSelector from 'rememo'; * WordPress dependencies */ import { createRegistrySelector } from '@wordpress/data'; +import { store as bindingsStore } from '@wordpress/bindings'; /** * Internal dependencies @@ -23,6 +24,11 @@ import { INSERTER_PATTERN_TYPES } from '../components/inserter/block-patterns-ta import { STORE_NAME } from './constants'; import { unlock } from '../lock-unlock'; import { selectBlockPatternsKey } from './private-keys'; +import { RichTextData } from '@wordpress/rich-text'; +import { + canBindAttribute, + canBindBlock, +} from '../../../editor/src/bindings/utils'; export { getBlockSettings } from './get-block-settings'; @@ -353,3 +359,130 @@ export function getLastFocus( state ) { export function isDragging( state ) { return state.isDragging; } + +/** + * Return the bindings connection key + * for a given block client ID and attribute. + * + * @param {Object} state - Global application state. + * @param {string} clientId - Block client ID. + * @param {string} attribute - Block attribute name. + * @return {string} Bingins connection key. + */ +export function getBindingsConnectionKey( state, clientId, attribute ) { + if ( ! state.bindings ) { + return {}; + } + + return state.bindings?.byClientId.get( clientId )?.connectionKeys[ + attribute + ]; +} + +/** + * Returns a blocks with bound attributes collection. + * + * @param {Object} state - Global application state. + * @return {Object} Block with bound attributes collection. + */ +export function getBlocksWithBoundAttributes( state ) { + // If there are no bindings, return an empty object. + if ( ! state.bindings?.byClientId ) { + return {}; + } + + const result = {}; + + state.bindings.byClientId.forEach( ( block, clientId ) => { + if ( ! canBindBlock( block.name ) ) { + return; + } + + // Check if the attribute can be bound. + const boundAttributes = Object.fromEntries( + Object.entries( block.attributes || {} ).filter( + ( [ attribute ] ) => { + return canBindAttribute( block.name, attribute ); + } + ) + ); + + if ( Object.keys( boundAttributes ).length === 0 ) { + return; + } + + result[ clientId ] = boundAttributes; + } ); + + return result; +} + +/** + * Return a list of blocks that are bound + * to the same bindings connection key. + * + * @param {Object} state - Global application state. + * @param {string} key - Bingins connection key. + * @param {string} value - Bingins connection value. + * @return {Object[]} List of blocks with the same bindings connection key. + */ +export function getBlocksByBindingsConnectionKey( state, key, value ) { + const bindingsConnection = state.bindings.connections.get( key ); + if ( ! bindingsConnection ) { + return []; + } + + return Object.entries( bindingsConnection ).map( + ( [ attr, clientIds ] ) => { + return { + attributes: { [ attr ]: value }, + clientIds, + }; + } + ); +} + +/** + * Selects and optionally transforms + * the value bound to an external property. + * + * @param {Object} state - Redux state. + * @param {string} key - External property key. + * @param {string|RichTextData} attribute - Current attribute value. + * @return {string|RichTextData} Transformed or original bound attribute. + */ +export const getBoundAttributeValue = createRegistrySelector( ( select ) => + createSelector( ( state, key, attribute ) => { + const { getBindingsConnectionValue } = unlock( + select( bindingsStore ) + ); + + const externalValue = getBindingsConnectionValue( key ); + + // Type: string + if ( typeof attribute === 'string' ) { + return externalValue; + } + + // Type: RichTextData + if ( attribute instanceof RichTextData ) { + /* + * Compare the string (HTML) value of the RichTextData + * with the external value. + * + * If they are the same, return the same attribute. + */ + if ( attribute.toHTMLString() === externalValue ) { + return attribute; + } + + /* + * Otherwise, return a RichTextData instance + * with the value of the external value. + */ + return RichTextData.fromHTMLString( externalValue ); + } + + return externalValue; + } ) +); diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index e836a44e12012f..2b7a5cc84c9645 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -131,6 +131,26 @@ function getFlattenedBlockAttributes( blocks ) { return flattenBlocks( blocks, ( block ) => block.attributes ); } +/** + * Given an array of blocks, returns an object containing all blocks + * that have bindings, recursing into inner blocks. + * Keys correspond to the block client ID, the value contains the following properties: + * - name: the block name + * - attributes: the block attributes that have bindings. + * Keys correspond to the attribute name, the value contains the following properties: + * - source: The binding source handler name, + * - args: The binding arguments. + * + * @param {Array} blocks - Blocks to flatten. + * @return {Array} Flattened block. + */ +function getFlattenedBindingExternalProperties( blocks ) { + return flattenBlocks( blocks, ( block ) => ( { + name: block.name, + attributes: block.attributes?.metadata?.bindings, + } ) ).filter( ( [ , bindings ] ) => bindings.attributes !== undefined ); +} + /** * Returns true if the two object arguments have the same keys, or false * otherwise. @@ -258,6 +278,7 @@ const withBlockTree = } newState.tree = state.tree ? state.tree : new Map(); + switch ( action.type ) { case 'RECEIVE_BLOCKS': case 'INSERT_BLOCKS': { @@ -2044,7 +2065,95 @@ export function lastFocus( state = false, action ) { return state; } +const withBindingBlockReset = ( reducer ) => ( state, action ) => { + if ( action.type === 'RESET_BLOCKS' ) { + const newState = { + ...state, + byClientId: new Map( + getFlattenedBindingExternalProperties( action.blocks ) + ), + }; + return newState; + } + + return reducer( state, action ); +}; + +/** + * Reducer returning the block bindings state. + */ +export const bindings = pipe( + combineReducers, + withBindingBlockReset +)( { + byClientId( state = new Map(), action ) { + switch ( action.type ) { + case 'RESET_BINDING_CONNECTION_BLOCKS': { + const { clientId, attribute, key } = action; + const newState = new Map( state ); + + /* + * Populate ClientId entry + * with the property key bound to the block attributes. + */ + const currentBindings = newState.get( clientId ) || {}; + newState.set( clientId, { + ...currentBindings, + connectionKeys: { + ...currentBindings.connectionKeys, + [ attribute ]: key, + }, + } ); + + return newState; + } + } + + return state; + }, + + connections( state = new Map(), action ) { + switch ( action.type ) { + /* + * Collect the blocks with an attribute + * bound to the external property, + * organized by: + * { + * [connection-key_1]: { + * [attribute_1]: [ clientId_1, clientId_2, ... ], + * [attribute_2]: [ clientId_1, clientId_3, ... ], + * ... + * }, + * [connection-key_2]: { + * [attribute_1]: [ clientId_1, clientId_4, ... ], + * [attribute_3]: [ clientId_2, clientId_8, ... ], + * ... + * }, + * ... + * } + */ + case 'RESET_BINDING_CONNECTION_BLOCKS': { + const { clientId, attribute, key } = action; + const newState = new Map( state ); + + newState.set( key, { + ...newState.get( key ), + [ attribute ]: [ + ...( newState.get( key )?.[ attribute ] || [] ), + clientId, + ], + } ); + + return newState; + } + } + + return state; + }, +} ); + const combinedReducers = combineReducers( { + bindings, blocks, isDragging, isTyping, diff --git a/packages/block-library/package.json b/packages/block-library/package.json index 0a024f3a5f422e..215bb2cde1d219 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -34,6 +34,7 @@ "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/autop": "file:../autop", + "@wordpress/bindings": "file:../bindings", "@wordpress/blob": "file:../blob", "@wordpress/block-editor": "file:../block-editor", "@wordpress/blocks": "file:../blocks", diff --git a/packages/block-library/src/button/edit.js b/packages/block-library/src/button/edit.js index 24f82f1ba2f4f0..0165ef1cc1cb8c 100644 --- a/packages/block-library/src/button/edit.js +++ b/packages/block-library/src/button/edit.js @@ -2,6 +2,10 @@ * External dependencies */ import classnames from 'classnames'; +/** + * WordPress dependencies + */ +import { store as bindingsStore } from '@wordpress/bindings'; /** * Internal dependencies @@ -45,7 +49,6 @@ import { createBlock, cloneBlock, getDefaultBlockName, - store as blocksStore, } from '@wordpress/blocks'; import { useMergeRefs, useRefEffect } from '@wordpress/compose'; import { useSelect, useDispatch } from '@wordpress/data'; @@ -240,8 +243,8 @@ function ButtonEdit( props ) { } const blockBindingsSource = unlock( - select( blocksStore ) - ).getBlockBindingsSource( metadata?.bindings?.url?.source ); + select( bindingsStore ) + ).getBindingsSource( metadata?.bindings?.url?.source ); return { lockUrlControls: diff --git a/packages/block-library/src/image/edit.js b/packages/block-library/src/image/edit.js index fbbb2d481bee63..acdbc86820b193 100644 --- a/packages/block-library/src/image/edit.js +++ b/packages/block-library/src/image/edit.js @@ -7,7 +7,7 @@ import classnames from 'classnames'; * WordPress dependencies */ import { getBlobByURL, isBlobURL, revokeBlobURL } from '@wordpress/blob'; -import { store as blocksStore } from '@wordpress/blocks'; +import { store as bindingsStore } from '@wordpress/bindings'; import { Placeholder } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; import { @@ -343,8 +343,8 @@ export function ImageEdit( { } const blockBindingsSource = unlock( - select( blocksStore ) - ).getBlockBindingsSource( metadata?.bindings?.url?.source ); + select( bindingsStore ) + ).getBindingsSource( metadata?.bindings?.url?.source ); return { lockUrlControls: @@ -362,6 +362,7 @@ export function ImageEdit( { }, [ isSingleSelected ] ); + const placeholder = ( content ) => { return ( 0; - const urlBindingSource = getBlockBindingsSource( - urlBinding?.source - ); - const altBindingSource = getBlockBindingsSource( - altBinding?.source - ); - const titleBindingSource = getBlockBindingsSource( + const urlBindingSource = getBindingsSource( urlBinding?.source ); + const altBindingSource = getBindingsSource( altBinding?.source ); + const titleBindingSource = getBindingsSource( titleBinding?.source ); return { @@ -464,8 +461,8 @@ export default function Image( { ( ! urlBindingSource || urlBindingSource?.lockAttributesEditing ), lockHrefControls: - // Disable editing the link of the URL if the image is inside a pattern instance. - // This is a temporary solution until we support overriding the link on the frontend. + // // Disable editing the link of the URL if the image is inside a pattern instance. + // // This is a temporary solution until we support overriding the link on the frontend. hasParentPattern, lockCaption: // Disable editing the caption if the image is inside a pattern instance. diff --git a/packages/blocks/src/store/private-actions.js b/packages/blocks/src/store/private-actions.js index d609f70b91b55d..ec89011b130cd9 100644 --- a/packages/blocks/src/store/private-actions.js +++ b/packages/blocks/src/store/private-actions.js @@ -51,7 +51,7 @@ export function registerBlockBindingsSource( source ) { type: 'REGISTER_BLOCK_BINDINGS_SOURCE', sourceName: source.name, sourceLabel: source.label, - useSource: source.useSource, - lockAttributesEditing: source.lockAttributesEditing, + connect: source.connect, + lockAttributesEditing: false, }; } diff --git a/packages/blocks/src/store/reducer.js b/packages/blocks/src/store/reducer.js index f92fb376b530a7..5d771d7560a25c 100644 --- a/packages/blocks/src/store/reducer.js +++ b/packages/blocks/src/store/reducer.js @@ -389,8 +389,8 @@ export function blockBindingsSources( state = {}, action ) { ...state, [ action.sourceName ]: { label: action.sourceLabel, - useSource: action.useSource, - lockAttributesEditing: action.lockAttributesEditing ?? true, + connect: action.connect, + lockAttributesEditing: false, }, }; } diff --git a/packages/editor/package.json b/packages/editor/package.json index 507cc6b9d218ee..f4d442899ba12b 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -33,6 +33,7 @@ "@babel/runtime": "^7.16.0", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", + "@wordpress/bindings": "file:../bindings", "@wordpress/blob": "file:../blob", "@wordpress/block-editor": "file:../block-editor", "@wordpress/blocks": "file:../blocks", diff --git a/packages/editor/src/bindings/index.js b/packages/editor/src/bindings/index.js index ce77b87ebc7a5c..bf13d0a139ab03 100644 --- a/packages/editor/src/bindings/index.js +++ b/packages/editor/src/bindings/index.js @@ -1,18 +1,28 @@ /** * WordPress dependencies */ -import { store as blocksStore } from '@wordpress/blocks'; +import { store as bindingsStore } from '@wordpress/bindings'; import { dispatch } from '@wordpress/data'; + /** * Internal dependencies */ import { unlock } from '../lock-unlock'; import patternOverrides from './pattern-overrides'; import postMeta from './post-meta'; +import postEntity from './post-entity'; + +const { registerBindingsSource } = unlock( dispatch( bindingsStore ) ); + +// Lock attributes editing as default. +postMeta.lockAttributesEditing = + typeof postMeta.lockAttributesEditing === 'undefined' + ? true + : postMeta.lockAttributesEditing; -const { registerBlockBindingsSource } = unlock( dispatch( blocksStore ) ); -registerBlockBindingsSource( postMeta ); +registerBindingsSource( postMeta ); +registerBindingsSource( postEntity ); if ( process.env.IS_GUTENBERG_PLUGIN ) { - registerBlockBindingsSource( patternOverrides ); + registerBindingsSource( patternOverrides ); } diff --git a/packages/editor/src/bindings/post-entity.js b/packages/editor/src/bindings/post-entity.js new file mode 100644 index 00000000000000..96ff1db3323234 --- /dev/null +++ b/packages/editor/src/bindings/post-entity.js @@ -0,0 +1,46 @@ +/** + * WordPress dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { __ } from '@wordpress/i18n'; +import { select, dispatch } from '@wordpress/data'; +/** + * Internal dependencies + */ +import { store as editorStore } from '../store'; +import { RichTextData } from '@wordpress/rich-text'; + +export default { + name: 'core/post-entity', + label: __( 'Post Entity' ), + connect( { prop, id } ) { + if ( ! prop ) { + throw new Error( 'The "prop" argument is required.' ); + } + + const { getEditedEntityRecord } = select( coreStore ); + const { editEntityRecord } = dispatch( coreStore ); + const { getCurrentPostId } = select( editorStore ); + + id = id || getCurrentPostId(); + + return { + get: () => { + const record = getEditedEntityRecord( 'postType', 'post', id ); + return record[ prop ]?.rendered || record[ prop ]; + }, + + update: ( newValue ) => { + if ( newValue instanceof RichTextData ) { + newValue = newValue.toString(); + } + + editEntityRecord( 'postType', 'post', id, { + [ prop ]: newValue, + } ); + }, + }; + }, + + lockAttributesEditing: false, +}; diff --git a/packages/editor/src/bindings/post-meta.js b/packages/editor/src/bindings/post-meta.js index 0d0c737d0eaf77..bd3d90ed01ad4f 100644 --- a/packages/editor/src/bindings/post-meta.js +++ b/packages/editor/src/bindings/post-meta.js @@ -1,8 +1,8 @@ /** * WordPress dependencies */ -import { useEntityProp } from '@wordpress/core-data'; -import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { select, dispatch } from '@wordpress/data'; import { _x } from '@wordpress/i18n'; /** * Internal dependencies @@ -12,33 +12,26 @@ import { store as editorStore } from '../store'; export default { name: 'core/post-meta', label: _x( 'Post Meta', 'block bindings source' ), - useSource( props, sourceAttributes ) { - const { getCurrentPostType } = useSelect( editorStore ); - const { context } = props; - const { key: metaKey } = sourceAttributes; - const postType = context.postType - ? context.postType - : getCurrentPostType(); + connect( { key, id } ) { + const { getEditedEntityRecord } = select( coreStore ); + const { editEntityRecord } = dispatch( coreStore ); + const { getCurrentPostId } = select( editorStore ); - 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 } ); - }; + id = id || getCurrentPostId(); return { - placeholder: metaKey, - value: metaValue, - updateValue: updateMetaValue, + get: () => + getEditedEntityRecord( 'postType', 'post', id ).meta[ key ], + + update: ( value ) => { + editEntityRecord( 'postType', 'post', id, { + meta: { + ...getEditedEntityRecord( 'postType', 'post', id ).meta, + [ key ]: value, + }, + } ); + }, }; }, + lockAttributesEditing: false, }; diff --git a/packages/editor/src/bindings/utils.js b/packages/editor/src/bindings/utils.js new file mode 100644 index 00000000000000..f3cf8fa1dde06b --- /dev/null +++ b/packages/editor/src/bindings/utils.js @@ -0,0 +1,32 @@ +const BLOCK_BINDINGS_ALLOWED_BLOCKS = { + 'core/paragraph': [ 'content' ], + 'core/heading': [ 'content' ], + 'core/image': [ 'url', 'title', 'alt' ], + 'core/button': [ 'url', 'text', 'linkTarget' ], +}; + +/** + * Based on the given block name, + * check if it is possible to bind the block. + * + * @param {string} blockName - The block name. + * @return {boolean} Whether it is possible to bind the block to sources. + */ +export function canBindBlock( blockName ) { + return blockName in BLOCK_BINDINGS_ALLOWED_BLOCKS; +} + +/** + * Based on the given block name and attribute name, + * check if it is possible to bind the block attribute. + * + * @param {string} blockName - The block name. + * @param {string} attributeName - The attribute name. + * @return {boolean} Whether it is possible to bind the block attribute. + */ +export function canBindAttribute( blockName, attributeName ) { + return ( + canBindBlock( blockName ) && + BLOCK_BINDINGS_ALLOWED_BLOCKS[ blockName ].includes( attributeName ) + ); +}