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

Blocks: Handle pressing backspace at the beginning of blocks #461

Merged
merged 9 commits into from
Apr 27, 2017

Conversation

youknowriad
Copy link
Contributor

@youknowriad youknowriad commented Apr 20, 2017

In this PR, I'm handling the backspace behaviour. I've tried to keep the API needed for this as minimal as possible:

  • A single callback mergeWithPrevious to be called when we hit backspace at the beginning of a block.
  • A merge function responsible of merging attributes of a block with the same type.

The different use cases are:

  • If the merge function is not defined in a block, this means the blocks is an "object" block that can not be merged (think image, embed, gallery), thus, the block is removed when we hit backspace in a following block.

  • If the merge function is defined, we can merge this block with the following one but before doing that we need to match the block types of these two blocks. We transform the following block to the same block type and then we call the merge function to merge them. If the following block can not be switched to the current block, this means these two blocks are not mergeable.

@youknowriad youknowriad added [Feature] Blocks Overall functionality of blocks Framework Issues related to broader framework topics, especially as it relates to javascript labels Apr 20, 2017
@youknowriad youknowriad self-assigned this Apr 20, 2017
@youknowriad
Copy link
Contributor Author

I could use some 👀 here

Copy link
Member

@aduth aduth left a comment

Choose a reason for hiding this comment

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

Seems #466 will conflict with some of the ideas here.


// If the block types can not match, do nothing
if ( ! blockWithTheSameType ) {
return;
Copy link
Member

Choose a reason for hiding this comment

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

Do we want to remove the previous block here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good question. I don't really know the right answer. I think this use-case might not happen as often as we think because I'm assuming the majority of textual blocks could be transformed in between'em but It's a possibility.

I preferred to avoid deletion and do nothing but I don't have strong arguments for one or another.

Copy link
Member

Choose a reason for hiding this comment

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

My main reason for bringing it up was merely as a point of consistency, since it seemed from the above condition that the "unhandleable" case should simply be removed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not considering the "merge not defined" as "unhandleable" but more like, if you want this block to be removed when we hit backspace, don't define the merge

Copy link
Member

Choose a reason for hiding this comment

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

I'm not considering the "merge not defined" as "unhandleable" but more like, if you want this block to be removed when we hit backspace, don't define the merge

Isn't that the condition above though, not this one?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes?

Copy link
Member

Choose a reason for hiding this comment

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

Oh, I misread your previous message as "if you don't want this block to be removed". Disregard.

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 fine, but we should probably document the behavior somewhere.

Copy link
Member

Choose a reason for hiding this comment

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

I'd very much prefer that we don't silently delete content here. Submitted a PR to change this behavior - #530

@aduth
Copy link
Member

aduth commented Apr 21, 2017

Seems #466 will conflict with some of the ideas here.

Might not be that bad actually, basically turns from concatenating strings to creating an array of two content members.

( dispatch, ownProps ) => ( {
onChange( updates ) {
onChange( uid, updates ) {
Copy link
Member

Choose a reason for hiding this comment

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

Minor: I don't love that we have some props that accept a uid and others that assume it from the other props. I understand the need for it and don't have a great alternative.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do you think making the uid mandatory even for callbacks we're using only for the current block (and using _.partial ) is a good move?

Copy link
Member

Choose a reason for hiding this comment

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

I like that idea because it promotes consistency, but I don't like that idea because it loses the sense that the component is meant to be specific to a single block.

}

onKeyDown( event ) {
if ( this.props.onMerge && event.keyCode === KEYCODE_BACKSPACE && this.isStartOfEditor() ) {
Copy link
Member

Choose a reason for hiding this comment

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

I don't know if it was intentional, but nice optimization having the heaviest part of the condition performed last and taking advantage of short-circuiting 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Intentional :)

@aduth
Copy link
Member

aduth commented Apr 24, 2017

Do you think this could qualify as a third transforms type: from, to, and now merge?

@youknowriad
Copy link
Contributor Author

Do you think this could qualify as a third transforms type: from, to, and now merge?

I don't have a preference. Merging is not really a transformation but it returns attributes in the same way the transformations do. Answering a question by another one :P, what's your opinion on this? :)

@aduth
Copy link
Member

aduth commented Apr 24, 2017

I hadn't thought of it 'til today, and only came to mind as an attempt to consider if we could eliminate the merge and use only to and from. Eventually I relented and accepted the need for merge, but the thought process and considering it alike to and from made me think whether merge could be treated in a similar way. I also do not feel strongly; to and from is another nice duality. We maybe also shouldn't treat merge as first-class, since it's quite specific to blocks containing Editable fields (not all blocks).

element/index.js Outdated
@@ -65,3 +66,21 @@ export function renderToString( element ) {

return renderToStaticMarkup( element );
}


export function concatValues( value1, value2 ) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@aduth Concatenating children values is not that easy, can you think of a simpler alternative?

@@ -10,6 +10,7 @@ import { Parser as HtmlToReactParser } from 'html-to-react';
*/
import './style.scss';

const KEYCODE_BACKSPACE = 8;
Copy link
Member

Choose a reason for hiding this comment

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

There's also window.tinymce.util.VK. It's okay like this I guess, but at some point we may have to use other things from it anyway, like metaKeyPressed.

Copy link
Member

Choose a reason for hiding this comment

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

At some point in the near future we may also consider leveraging the now-preferred event.key property which, funny enough, is better supported in IE back to IE11 than it is in Safari (only available in most recent versions).

do {
const child = element;
element = element.parentNode;
if ( element.childNodes[ 0 ] !== child ) {
Copy link
Member

@ellatrix ellatrix Apr 24, 2017

Choose a reason for hiding this comment

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

element.firstChild? (It's faster)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍

Copy link
Member

Choose a reason for hiding this comment

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

element.firstChild? (It's faster)

I agree with the suggestion but now I'm intrigued in what way it would be faster? Seems like the difference is array access at an index, which I wouldn't expect to be slow.

Copy link
Member

Choose a reason for hiding this comment

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

@aduth I honestly have no idea :) If you find out, please let me know. :)

Copy link
Member

Choose a reason for hiding this comment

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

It's also not just array access?

element/index.js Outdated
@@ -65,3 +66,20 @@ export function renderToString( element ) {

return renderToStaticMarkup( element );
}

export function concatValues( value1, value2 ) {
Copy link
Member

Choose a reason for hiding this comment

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

If we are to drop the element React abstraction, what's to happen to these utility functions?

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 function effectively concatenating two element trees? Could the name more accurately reflect this? "values" is not a term that means much to me in a React sense.

Copy link
Contributor Author

@youknowriad youknowriad Apr 24, 2017

Choose a reason for hiding this comment

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

concatChildren ?

If we are to drop the element React abstraction, what's to happen to these utility functions?

Yep, we should consider moving those to a utils inside the editor folder maybe.

Copy link
Member

Choose a reason for hiding this comment

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

concatChildren ?

I like this, as it seems like if this were a function native to React, it'd be called React.Children.concat

element/index.js Outdated
@@ -65,3 +66,20 @@ export function renderToString( element ) {

return renderToStaticMarkup( element );
}

export function concatValues( value1, value2 ) {
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 function effectively concatenating two element trees? Could the name more accurately reflect this? "values" is not a term that means much to me in a React sense.

element/index.js Outdated
@@ -65,3 +66,20 @@ export function renderToString( element ) {

return renderToStaticMarkup( element );
}

export function concatValues( value1, value2 ) {
const toArray = value => Array.isArray( value ) ? Children.toArray( value ) : [ value ];
Copy link
Member

Choose a reason for hiding this comment

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

Is it not enough to use Children.toArray in all cases here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That was my first guess, but it's not working as expected on string values

element/index.js Outdated
export function concatValues( value1, value2 ) {
const toArray = value => Array.isArray( value ) ? Children.toArray( value ) : [ value ];

return toArray( value1 )
Copy link
Member

Choose a reason for hiding this comment

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

Could this function be simplified to...

return [
	...Children.toArray( value1 ),
	...Children.toArray( value2 )
];

?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

no, Children.toArray is not enough, and I figured out, I'd reset the keys to avoid any confusion/duplication

element/index.js Outdated
key: index
};
} );
}
Copy link
Member

Choose a reason for hiding this comment

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

Evidenced by my confusion on the behavior of this function, would be good to add some JSDoc and test cases.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, and unit tests too :)

( state, ownProps ) => {
const order = state.blocks.order.indexOf( ownProps.uid );
return {
previousBlock: order === 0 ? null : state.blocks.byUid[ state.blocks.order[ order - 1 ] ],
Copy link
Member

Choose a reason for hiding this comment

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

Maybe this should be <= 0 to account for the chance that uid is not present in the order array?

Alternatively perhaps the fallback could occur as an || case:

previousBlock: state.blocks.byUid[ state.blocks.order[ state.blocks.order.indexOf( ownProps.uid ) - 1 ] ] || null

(with better formatting)

@youknowriad
Copy link
Contributor Author

Any other concern here?

@aduth
Copy link
Member

aduth commented Apr 25, 2017

Do we want to address focus issues separately? I'd hoped to be able to split and restore a block cleanly, but appears focus is lost after the merge:

  1. Click in middle of text block
  2. Hit enter twice
  3. Hit backspace twice

Expected: Text block to have same text as in step 1

@youknowriad
Copy link
Contributor Author

@aduth Yeah, I noticed and the fix for this would be to implement the focus offset (at least a special value to specify the end of an Editable, maybe -1) but I was hoping to fix this as a separate PR.

@aduth
Copy link
Member

aduth commented Apr 25, 2017

I cannot merge two blocks if the second block has no content.

@aduth
Copy link
Member

aduth commented Apr 25, 2017

Fine to handle separately (though maybe good to create an issue), but we should also handle the forward deletion merge.

@youknowriad
Copy link
Contributor Author

I cannot merge two blocks if the second block has no content.

@aduth This is working for me. Can you detail your use case (what kind of blocks etc...)

@aduth
Copy link
Member

aduth commented Apr 25, 2017

Error when backspacing on new block paragraph:

  1. Select end of first text block
  2. Press enter twice
  3. Press backspace
  4. Block is eventually removed but an error is logged to the console

err

Unable to backspace second of two empty headings added to post:

  1. Click inserter at bottom of post
  2. Insert a heading
  3. Repeat steps 1 and 2 to add a second heading
  4. Press backspace while second empty heading is focused
  5. Nothing happens

headings

@youknowriad
Copy link
Contributor Author

Thanks for the details @aduth

Error when backspacing on new block paragraph

This was caused because TinyMCE is handling the keydown internally and we're destroying TinyMCE on our call to onMerge. The fix in d091952 is not ideal, I'm debouncing the onMerge call. Let me know if you have a better idea.

Unable to backspace second of two empty headings added to post

Oh thanks! I was trying to reproduce, by splitting and backspace which was working. Fixed in fbef18c

@aduth
Copy link
Member

aduth commented Apr 26, 2017

This was caused because TinyMCE is handling the keydown internally and we're destroying TinyMCE on our call to onMerge. The fix in d091952 is not ideal, I'm debouncing the onMerge call. Let me know if you have a better idea.

TinyMCE's internal event handling respects propagation being stopped, so in 5a32acf I changed to test whether the editor was removed after the merge to determine whether to stop.

element/index.js Outdated
*
* @return {Array} The concatenation
*/
export function concatChildren( children1, children2 ) {
Copy link
Member

Choose a reason for hiding this comment

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

I was poking around at this function, and had a couple observations:

  • I think it'd be easy enough and a nice feature to support a spread value of ...childrens, so two or more set of children can be concatenated
  • I'm still unclear why toArray need exist. You'd mentioned different values, but in my testing Children.toArray seems to handle most everything thrown at it.
  • I think we might be able to drop an iteration by creating the return value while iterating children (instead of toArray then map)

Do you think something like this would work?

/**
 * Concatenate two or more React children objects
 *
 * @param  {...?Object} childrens Set of children to concatenate
 * @return {Array}                The concatenated value
 */
export function concatChildren( ...childrens ) {
	return childrens.reduce( ( memo, children, i ) => {
		Children.forEach( children, ( child, l ) => {
			memo.push( cloneElement( child, {
				key: [ i, l ].join()
			} ) );
		} );

		return memo;
	}, [] );
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think it'd be easy enough and a nice feature to support a spread value of ...childrens, so two or more set of children can be concatenated

Great idea!


I've tried your version but it's not working. I'm finding the toArray to be necessary. I'm unsure if it may be a problem coming from another place (parser or anything), but you can easily reproduce a bug by backspacing from the start of the second test block "I imagine..."

Copy link
Member

Choose a reason for hiding this comment

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

Huh, yeah, I can reproduce that. Seems to be an issue at the point of calling cloneElement on the string though. This variation seems to work okay for me:

/**
 * Concatenate two or more React children objects
 *
 * @param  {...?Object} childrens Set of children to concatenate
 * @return {Array}                The concatenated value
 */
export function concatChildren( ...childrens ) {
	return childrens.reduce( ( memo, children, i ) => {
		Children.forEach( children, ( child, j ) => {
			if ( 'string' !== typeof child ) {
				child = cloneElement( child, {
					key: [ i, j ].join()
				} );
			}

			memo.push( child );
		} );

		return memo;
	}, [] );
}

@youknowriad
Copy link
Contributor Author

@aduth Updated the concatChildren. I'm merging to move forward the other blocked PRs.

@youknowriad youknowriad merged commit eb4cb05 into master Apr 27, 2017
@youknowriad youknowriad deleted the add/merge-api/backspace branch April 27, 2017 08:36
@aduth aduth mentioned this pull request Mar 29, 2019
9 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Feature] Blocks Overall functionality of blocks Framework Issues related to broader framework topics, especially as it relates to javascript
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants