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: Fix focus behavior when pressing Enter on connected block #58958

Closed
wants to merge 12 commits into from
Closed
71 changes: 15 additions & 56 deletions packages/block-editor/src/components/rich-text/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
removeFormat,
} from '@wordpress/rich-text';
import { Popover } from '@wordpress/components';
import { getBlockType, store as blocksStore } from '@wordpress/blocks';

/**
* Internal dependencies
Expand All @@ -45,8 +44,7 @@ import FormatEdit from './format-edit';
import { getAllowedFormats } from './utils';
import { Content } from './content';
import { withDeprecations } from './with-deprecations';
import { unlock } from '../../lock-unlock';
import { BLOCK_BINDINGS_ALLOWED_BLOCKS } from '../../hooks/use-bindings-attributes';
import { useShouldDisableEditing } from './use-should-disable-editing';

export const keyboardShortcutContext = createContext();
export const inputEventContext = createContext();
Expand Down Expand Up @@ -117,24 +115,18 @@ export function RichTextWrapper(
props = removeNativeProps( props );

const anchorRef = useRef();
const {
clientId,
isSelected: isBlockSelected,
name: blockName,
} = useBlockEditContext();
const { clientId, isSelected: isBlockSelected } = 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, getBlockAttributes } =
const { getSelectionStart, getSelectionEnd } =
select( blockEditorStore );
const selectionStart = getSelectionStart();
const selectionEnd = getSelectionEnd();
const blockBindings =
getBlockAttributes( clientId )?.metadata?.bindings;

let isSelected;

Expand All @@ -147,53 +139,22 @@ export function RichTextWrapper(
isSelected = selectionStart.clientId === clientId;
}

// Disable Rich Text editing if block bindings specify that.
let disableBoundBlocks = false;
if ( blockBindings && blockName in BLOCK_BINDINGS_ALLOWED_BLOCKS ) {
const blockTypeAttributes = getBlockType( blockName ).attributes;
const { getBlockBindingsSource } = unlock( select( blocksStore ) );
for ( const [ attribute, args ] of Object.entries(
blockBindings
) ) {
if (
blockTypeAttributes?.[ attribute ]?.source !== 'rich-text'
) {
break;
}

// If the source is not defined, or if its value of `lockAttributesEditing` is `true`, disable it.
const blockBindingsSource = getBlockBindingsSource(
args.source
);
if (
! blockBindingsSource ||
blockBindingsSource.lockAttributesEditing
) {
disableBoundBlocks = true;
break;
}
}
}

return {
selectionStart: isSelected ? selectionStart.offset : undefined,
selectionEnd: isSelected ? selectionEnd.offset : undefined,
isSelected,
disableBoundBlocks,
};
};
const { selectionStart, selectionEnd, isSelected, disableBoundBlocks } =
useSelect( selector, [
clientId,
identifier,
originalIsSelected,
isBlockSelected,
] );

const shouldDisableEditing = disableEditing || disableBoundBlocks;

const { selectionStart, selectionEnd, isSelected } = useSelect( selector, [
clientId,
identifier,
originalIsSelected,
isBlockSelected,
] );
const { getSelectionStart, getSelectionEnd, getBlockRootClientId } =
useSelect( blockEditorStore );
// Disable Rich Text editing if block bindings specify that.
const shouldDisableEditing = useShouldDisableEditing( identifier );
const { selectionChange } = useDispatch( blockEditorStore );
const adjustedAllowedFormats = getAllowedFormats( {
allowedFormats,
Expand Down Expand Up @@ -333,6 +294,7 @@ export function RichTextWrapper(
}

const TagName = tagName;
const tabIndex = props.tabIndex === 0 ? null : props.tabIndex;
return (
<>
{ isSelected && (
Expand Down Expand Up @@ -415,6 +377,7 @@ export function RichTextWrapper(
disableLineBreaks,
onSplitAtEnd,
onSplitAtDoubleLineEnd,
shouldDisableEditing,
} ),
useFirefoxCompat(),
anchorRef,
Expand All @@ -426,17 +389,13 @@ export function RichTextWrapper(
props.className,
'rich-text'
) }
// Setting tabIndex to 0 is unnecessary, the element is already
// Setting tabIndex to 0 is unnecessary, if the element is already
// focusable because it's contentEditable. This also fixes a
// Safari bug where it's not possible to Shift+Click multi
// 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 && ! shouldDisableEditing
? null
: props.tabIndex
}
tabIndex={ shouldDisableEditing ? 0 : tabIndex }
data-wp-block-attribute-key={ identifier }
/>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function useEnter( props ) {
disableLineBreaks,
onSplitAtEnd,
onSplitAtDoubleLineEnd,
shouldDisableEditing,
} = propsRef.current;

event.preventDefault();
Expand Down Expand Up @@ -67,7 +68,13 @@ export function useEnter( props ) {

const { text, start, end } = _value;

if ( event.shiftKey ) {
if ( shouldDisableEditing ) {
Copy link
Member

Choose a reason for hiding this comment

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

Can you explain what that branch of the code is doing?

Copy link
Contributor

Choose a reason for hiding this comment

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

I believe this explanation about how splitValue works should help to understand this as well: link.

Another option here could be to change _value.end = _value.text?.length.

From my testing, it seems that when the rich text is not editable, it acts as if the cursor was in the initial character. This causes that when you click on enter, it doesn't work as expected. For that reason, I believe we could assume that when shouldDisableEditing is true, we don't have to add any text to the new block. Or we want to simulate placing the cursor at the end.

Copy link
Member

Choose a reason for hiding this comment

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

I see. I was totally lost, so that made it clear. I think simulating the case that the cursor is at the end should do the trick for most of the blocks 💯

Copy link
Contributor

Choose a reason for hiding this comment

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

Just changed that part in this commit.

// Simulate the cursor is at the end of the rich text.
_value.start = _value.text?.length;
_value.end = _value.text?.length;
Copy link
Member

@ellatrix ellatrix Feb 23, 2024

Choose a reason for hiding this comment

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

We shouldn't have to do this stuff if we're just disabling rich text

}

if ( event.shiftKey && ! shouldDisableEditing ) {
if ( ! disableLineBreaks ) {
onChange( insert( _value, '\n' ) );
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* WordPress dependencies
*/
import { store as blocksStore } from '@wordpress/blocks';
import { useSelect } from '@wordpress/data';

/**
* Internal dependencies
*/
import { BLOCK_BINDINGS_ALLOWED_BLOCKS } from '../../hooks/use-bindings-attributes';
import { useBlockEditContext } from '../block-edit';
import { store as blockEditorStore } from '../../store';
import { unlock } from '../../lock-unlock';

export function useShouldDisableEditing( attributeName ) {
const { clientId, name: blockName } = useBlockEditContext();
return useSelect(
( select ) => {
if (
! attributeName ||
! BLOCK_BINDINGS_ALLOWED_BLOCKS[ blockName ]?.includes(
attributeName
)
) {
return false;
}
const blockBindings =
select( blockEditorStore ).getBlockAttributes( clientId )
Copy link
Member

Choose a reason for hiding this comment

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

This is a performance problem because we're adding another subscription to block attributes to every block (at least paragraph and all other blocks with binding support in this case). I used context in #59320.

?.metadata?.bindings;
if ( ! blockBindings?.[ attributeName ]?.source ) {
return false;
}
const blockBindingsSource = unlock(
select( blocksStore )
).getBlockBindingsSource( blockBindings[ attributeName ].source );
return blockBindingsSource?.lockAttributesEditing !== false;
},
[ clientId, blockName, attributeName ]
);
}
23 changes: 17 additions & 6 deletions packages/block-library/src/button/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -293,12 +293,23 @@ function ButtonEdit( props ) {
...spacingProps.style,
...shadowProps.style,
} }
onSplit={ ( value ) =>
createBlock( 'core/button', {
...attributes,
text: value,
} )
}
onSplit={ ( value, isOriginal ) => {
let newAttributes;
Copy link
Member

Choose a reason for hiding this comment

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

I would be also curious to better understand the changes applied here.

Copy link
Contributor

@SantosGuillamot SantosGuillamot Feb 23, 2024

Choose a reason for hiding this comment

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

Okay, I'll try to do my best from what I understood from my testings 😄

When you use onSplit and call the splitValue function, there are some things to take into account:

  • When you click enter it splits the blocks into two.
  • Where you have the cursor delimits, what's gonna be part of the first block (content to the left), and part of the new block (content to the right).
  • I believe it actually creates two blocks: The original one and a new one. And this onSplit function runs for both of them.

With the previous logic, we were passing the attributes even if it wasn't the original block and its value it's empty. This happens when you click enter at the end of the block, with the cursor in the last character. You only want to preserve some value and the attributes when you actually split the text.

Actually, I just realized that we are preserving the metadata in the new block, which I believe it is not correct. For example, if I have a button and rename it to "Custom button" with text "My button", I put the cursor in the middle and I click Enter, the new block has the "Custom button" name as well. It should be easy to fix just checking if it is the original block or not. I can work on that.

Anyway, I believe this last part is not related to this PR because it happens with not bound blocks using rich text: paragraph, heading, button. It could be fixed changing the conditional to something like this:

if ( isOriginal ) {
  newAttributes = {
    ...attributes,
    text: value,
  };
} else if ( value ) {
  newAttributes = {
    ...attributes,
    metadata: undefined,
    text: value,
  }; 
}

But we can work on that in a follow-up if we really want to get rid of the metadata

Copy link
Member

Choose a reason for hiding this comment

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

Would it work this way?

newAttributes = {
    ...attributes,
    metadata: isOriginal ? attributes.metadata : undefined,
    text: value,
  }; 

Copy link
Contributor

Choose a reason for hiding this comment

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

I guess 🙂 Much better!

Copy link
Member

Choose a reason for hiding this comment

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

This seems unrelated to the main problem? Maybe split this out in a separate PR?

if ( isOriginal || value ) {
newAttributes = {
...attributes,
text: value,
};
}
const block = createBlock(
'core/button',
newAttributes
);
if ( isOriginal ) {
block.clientId = clientId;
Copy link
Member

Choose a reason for hiding this comment

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

Why is this necessary?

Copy link
Contributor

Choose a reason for hiding this comment

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

I assumed it is needed because when we split a paragraph or a button we are actually creating two new blocks, and I guess that createBlock generates a new clientId. However, we might want to keep the original clientId to simulate that it is actually still the original block.

}
return block;
} }
onReplace={ onReplace }
onMerge={ mergeBlocks }
identifier="text"
Expand Down
Loading