Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Block Bindings: Disable editing of bound block attributes in editor UI #58085

Merged
merged 27 commits into from
Jan 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2f071ad
Add actions and selectors to register new sources
SantosGuillamot Jan 22, 2024
57ab9b2
Add hook to read the bindings attribute in Edit
SantosGuillamot Jan 22, 2024
445405f
Add context to all the blocks with bindings
SantosGuillamot Jan 22, 2024
af7f870
Lock rich text when `isContentBound` is true
SantosGuillamot Jan 22, 2024
c3567a4
Adapt paragraph and heading blocks UI
SantosGuillamot Jan 22, 2024
35c8c64
Adapt button block UI
SantosGuillamot Jan 22, 2024
989f456
Adapt image block UI
SantosGuillamot Jan 22, 2024
a4dc34a
Register post meta source
SantosGuillamot Jan 22, 2024
3515538
Don't use placeholder if attribute is `src` or `href`
SantosGuillamot Jan 23, 2024
41709fa
Always share placeholder in case meta is empty
SantosGuillamot Jan 23, 2024
1567f17
Remove `keyToLabel` and use just label
SantosGuillamot Jan 23, 2024
1984749
Remove source component until it is needed
SantosGuillamot Jan 23, 2024
9644946
Use translations in the source label
SantosGuillamot Jan 23, 2024
478f861
Move `select` inside `useSource`
SantosGuillamot Jan 23, 2024
2acf6bd
Read `lockEditorUI` prop and add it for patterns
SantosGuillamot Jan 23, 2024
a8a6da3
Move logic to lock editing directly to RichText
SantosGuillamot Jan 23, 2024
30b635e
Improve `useSelect` destructuring
SantosGuillamot Jan 23, 2024
a6f5fde
Load all image controls if attributes are bound
SantosGuillamot Jan 23, 2024
7d0cb9a
Remove unnecessary condition
SantosGuillamot Jan 23, 2024
e6a5a4d
Move `lockAttributesEditing` to source definition
SantosGuillamot Jan 23, 2024
7c1ca5a
Move `useSelect` into existing hook
SantosGuillamot Jan 23, 2024
4246260
Fix `RichText` not being selected on click
SantosGuillamot Jan 23, 2024
5a0cee8
Lock button and image controls only when selected
SantosGuillamot Jan 23, 2024
54c313d
Remove unnecesarry optional chaining
SantosGuillamot Jan 24, 2024
aaf8652
Move `shouldDisableEditing` logic inside callback
SantosGuillamot Jan 24, 2024
1f34e08
Merge branch 'trunk' into add/block-bindings-support-in-the-editor
SantosGuillamot Jan 24, 2024
895e6dd
Fix formatting issue
SantosGuillamot Jan 24, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 48 additions & 10 deletions packages/block-editor/src/components/rich-text/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
removeFormat,
} from '@wordpress/rich-text';
import { Popover } from '@wordpress/components';
import { getBlockType } from '@wordpress/blocks';

/**
* Internal dependencies
Expand All @@ -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();
Expand Down Expand Up @@ -113,18 +115,24 @@ 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.
if ( ! isBlockSelected ) {
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;

Expand All @@ -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 );
Expand Down Expand Up @@ -376,7 +410,7 @@ export function RichTextWrapper(
useFirefoxCompat(),
anchorRef,
] ) }
contentEditable={ true }
contentEditable={ ! shouldDisableEditing }
SantosGuillamot marked this conversation as resolved.
Show resolved Hide resolved
suppressContentEditableWarning={ true }
className={ classnames(
'block-editor-rich-text__editable',
Expand All @@ -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 }
/>
</>
Expand Down
1 change: 1 addition & 0 deletions packages/block-editor/src/hooks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down
148 changes: 148 additions & 0 deletions packages/block-editor/src/hooks/use-bindings-attributes.js
Original file line number Diff line number Diff line change
@@ -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' ],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also add the linkTarget here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There were some issues with linkTarget. Let's keep it out of the scope for now.

};

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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any idea or advance of how it's going to implement the updateMetaValue usage?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The technical implementation is almost there. However, there are many things to take into account, and it was decided not to rush things for the upcoming 6.5 to ensure that whatever we land is stable enough.

const {
placeholder,
useValue: [ metaValue = null ] = [],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it metaValue a proper name here? In theory, the source can be arbitrary meaning it could belong to metadata, but also to very different origins.
Could we consider using something like sourceValue, and setSourceValue?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good feedback. On the server, there is get_value_callback registered for every block bindings source.

} = source.useSource(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a hook? You cannot use hooks inside conditions and loops

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the case of post meta source, it uses useSelect and useEntityProp: link. Although each source could do whatever they want.

If that's a problem, do you have any ideas on how that should be handled? We need to iterate through the different bindings and get the value from each of the sources.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only solution is to probably mount sub components, get the result, and set some state in this component, similar to how it is done here:

useBlockProps={ useBlockProps }

But I'd love to hear other people's thoughts

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch @ellatrix! Yup, we'll have to change it because it is a hook.

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 ) {
michalczaplinski marked this conversation as resolved.
Show resolved Hide resolved
updatedAttributes[ attributeName ] = metaValue;
}
}
}
);
}

const registry = useRegistry();

return (
<>
<BlockEdit
key="edit"
attributes={ updatedAttributes }
setAttributes={ ( newAttributes, blockId ) =>
registry.batch( () =>
updateBlockAttributes( blockId, newAttributes )
)
}
Comment on lines +93 to +97
Copy link
Member

@Mamaduka Mamaduka Feb 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • The blocks expect setAttributes to have a stable reference between rerenders. This override breaks that.
  • What's the reason for batching the single action? Technically, it shouldn't make a difference.

P.S. There is no need to wrap the returned BlockEdit in the fragment.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Mamaduka, I think the issue at the moment is that users will only change the attribute when it is not bound. When the value is sourced from the block binding then the attribute becomes readonly. In effect, the batching isn't really necessary and even there should be no override set until it's possible to edit the external source in UI.

By the way, feel free to refactor the code here if you have some ideas how to optimize it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the issue at the moment is that users will only change the attribute when it is not bound. When the value is sourced from the block binding then the attribute becomes readonly.

Is that handled somewhere else? Because I don't see that logic here. If the block calls the setAttribute override, it will update the attribute, bound or not.

Sorry, I'm not super familiar with the internals of Block Binding API. I just came across this code while looking for something else.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it will update the attribute, bound or not

There is no way to trigger the update from UI at the moment for supported blocks and their selected attributes. Direct changes to the store don't matter because the value will get replaced on the frontend anyway.

You are totally right that batching is not needed at the moment and most likely the override for setAttributes is premature.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for confirming. I can push refactoring PR later today.

{ ...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 );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What stops us from using the new "api" here? This will cause a bit of a performance regression.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to modify the attributes and setAttributes of the BlockEdit component with the new API? If that's possible I believe I could change it.


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;
}
);
SantosGuillamot marked this conversation as resolved.
Show resolved Hide resolved
10 changes: 10 additions & 0 deletions packages/block-editor/src/store/private-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
michalczaplinski marked this conversation as resolved.
Show resolved Hide resolved
8 changes: 8 additions & 0 deletions packages/block-editor/src/store/private-selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ];
}
15 changes: 15 additions & 0 deletions packages/block-editor/src/store/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -2062,6 +2076,7 @@ const combinedReducers = combineReducers( {
blockRemovalRules,
openedBlockSettingsMenu,
registeredInserterMediaCategories,
blockBindingsSources,
blockPatterns,
} );

Expand Down
Loading
Loading