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

Delete empty paragraphs when backspacing #3732

Merged

Conversation

noisysocks
Copy link
Member

@noisysocks noisysocks commented Nov 30, 2017

This is a rough attempt to fix #3624.

When backspacing from an empty paragraph into a block that has no merge function (e.g. an image), the empty paragraph should be deleted.

Out with the old:

old-behaviour

In with the new:

new-behaviour

How to test

  1. Have multiple paragraphs.
  2. Backspace to empty out a paragraph and backspace into previous one, verify the empty paragraph disappears.
  3. Now insert an image, and backspace into image from the empty paragraph block, verify that the empty paragraph disappears.

@codecov
Copy link

codecov bot commented Nov 30, 2017

Codecov Report

Merging #3732 into master will decrease coverage by 0.11%.
The diff coverage is 100%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #3732      +/-   ##
==========================================
- Coverage    37.7%   37.58%   -0.12%     
==========================================
  Files         279      278       -1     
  Lines        6737     6726      -11     
  Branches     1226     1227       +1     
==========================================
- Hits         2540     2528      -12     
- Misses       3536     3537       +1     
  Partials      661      661
Impacted Files Coverage Δ
editor/effects.js 58.2% <100%> (+0.63%) ⬆️
components/panel/row.js 0% <0%> (-100%) ⬇️
editor/utils/mobile/index.js 57.14% <0%> (-17.86%) ⬇️
editor/components/block-list/block.js 0.74% <0%> (-1.5%) ⬇️
editor/reducer.js 90.27% <0%> (-0.11%) ⬇️
editor/selectors.js 93.43% <0%> (-0.04%) ⬇️
element/index.js 100% <0%> (ø) ⬆️
blocks/hooks/index.js 100% <0%> (ø) ⬆️
editor/edit-post/layout/index.js 0% <0%> (ø) ⬆️
editor/store-defaults.js 100% <0%> (ø) ⬆️
... and 9 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 499b320...e5383ce. Read the comment docs.

@@ -217,6 +218,9 @@ export default {
// Only focus the previous block if it's not mergeable
if ( ! blockType.merge ) {
dispatch( focusBlock( blockA.uid ) );
if ( blockB.name === 'core/paragraph' && ! blockB.attributes.content.length ) {
Copy link
Member Author

Choose a reason for hiding this comment

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

My instinct here is to invent a isBlockEmpty helper which we would use instead of this very very specific logic.

I'm not sure, though, since it probably is only paragraphs that we want this behaviour for...

Thoughts, @aduth?

Copy link
Member

@gziolo gziolo Nov 30, 2017

Choose a reason for hiding this comment

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

We could also introduce a flag that marks all other removable blocks like heading or list.

blockB.attributes.content.length - is it safe to assume that content is there and it's a string?

Copy link
Member

Choose a reason for hiding this comment

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

And yes, you can at least introduce a local variable which better explains what is happening in this conditional statement :)

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe we could introduce a isEmpty property to the block API and if not defined the block is never empty. Only the block knows how to read its attributes.

This is another API to maintain but it makes sense to me. cc @aduth

Copy link
Member Author

@noisysocks noisysocks Dec 4, 2017

Choose a reason for hiding this comment

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

I added an isEmpty property and implemented it for core/paragraph. I agree that extra API isn't ideal, but this seems necessary.

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 love the isEmpty property (more to a general concern of limiting number of properties on the block API). It seems to me if Editable is smart enough to know that it should merge its own content into previous, it can also be smart enough to know that it has no content to merge and should just remove. That said, I also don't love an onRemove being added to Editable. Wondering if we could enhance one of the existing like onReplace to also encompass behavior of "replacing with nothing" ?

Copy link
Member Author

Choose a reason for hiding this comment

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

That's a good idea @aduth. I moved my deletion logic to Editable in a0d193b.

@noisysocks noisysocks added the [Type] Bug An existing feature does not function as intended label Nov 30, 2017
@gziolo
Copy link
Member

gziolo commented Nov 30, 2017

This merge property is also a bit strange, try the following:

Add an empty list and a quote and try to delete quote with backspace. It merges them together producing the list of 3 empty items:

empty-block

@noisysocks noisysocks force-pushed the try/deleting-empty-blocks-when-backspacing-into-an-image branch from 8d9cab7 to e5383ce Compare December 4, 2017 06:11
@noisysocks
Copy link
Member Author

noisysocks commented Dec 4, 2017

@gziolo it looks like that strangeness is caused by an unrelated bug in the core/quotecore/list transform. e5383ce ought to fix it!

@jasmussen
Copy link
Contributor

Seriously cool work. In my testing, behavior wise, this works exactly like it needs to! 👍 👍 from an experience point of view.

values: value.map( ( item, index ) => <li key={ index } >{ get( item, 'children.props.children', '' ) } </li> )
.concat( citation ? <li key="citation">{ citation }</li> : [] ),
values: value.map( item => get( item, 'children.props.children' ) ).concat( citation )
.filter( text => typeof text === 'string' )
Copy link
Member

Choose a reason for hiding this comment

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

This was a nice find that we are creating a list with an empty <li> and this affects some functionality 👍
Unfortunately, we cannot be that strict and force the text to be a string. For example, if we have a quote with items in bold they are ignored in the transformation because these items are not a string.
If the user adds a quote with a line then 3 empty lines and another line, I'm not certain if we should just transform to a list with two items or to a list with one item 3 empty items and one last item.
Maybe we can just add a special case that verifies if value and citation are both empty/unset and if yes we just return with a list with no items. e.g: if ( ( ! value || ! value.length ) && ! citation ) {.

Copy link
Member Author

@noisysocks noisysocks Dec 5, 2017

Choose a reason for hiding this comment

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

I've fiddled with this in e2257f5. There's a surprising amount of edge cases!

The logic I opted for is:

  • 'Quote' / 'Citation'<li>Quote</li><li>Citation</li>
  • '' / 'Citation'<li></li><li>Citation</li>
  • 'Quote' / ''<li>Quote</li>
  • '' / '' → ``

@noisysocks noisysocks force-pushed the try/deleting-empty-blocks-when-backspacing-into-an-image branch from e5383ce to e2257f5 Compare December 5, 2017 04:47
@aduth
Copy link
Member

aduth commented Dec 5, 2017

A small thing that I really appreciate from this change: Clicking into the "Write your story" and hitting Backspace resets to remove the inserted paragraph block.

const isEmpty = dom.isEmpty( rootNode );
if ( this.props.onReplace && isEmpty ) {
// Remove this block if the editor is empty
this.props.onReplace( [] );
Copy link
Member

Choose a reason for hiding this comment

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

One odd thing about the logic flow here: If I render an <Editable /> with onReplace, but not onMerge, I won't have this emptiness replace behavior, since this is only reached if onMerge is assigned, even though it's not called.

@aduth
Copy link
Member

aduth commented Dec 5, 2017

I should note to my review, functionally it works great and I'm glad to see it handled in Editable entirely.

@aduth
Copy link
Member

aduth commented Dec 5, 2017

Hmm, a potential issue is that some blocks may have multiple Editables, and one of them being empty is not a sufficient metric to determine that the block should be replaced. Example: Pressing backspace from a quote citation, when the quote itself has text, shouldn't delete the block.

Copy link
Contributor

@mcsf mcsf left a comment

Choose a reason for hiding this comment

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

Nice work, but it seems the replace behavior takes precedence over the merge one, leading to unexpected outcomes. Note how focus is lost when deleting into another paragraph:

Before After
gutenberg-3732-merge-p gutenberg-3732-replace-p

@noisysocks
Copy link
Member Author

Hmm, a potential issue is that some blocks may have multiple Editables, and one of them being empty is not a sufficient metric to determine that the block should be replaced.

Good point... there's a lot of dragons subtleties here.

This approach kind of works right now because in practice it's only core/paragraph that has both onMerge and onReplace passed to its Editable.

Unfortunately that means that the bug that this PR attempts to fix is only fixed for paragraph blocks and not quotes (which has onMerge but not onReplace):

quote-bug

If we give core/quote an onReplace, we fix the problem:

quote-bug-2

BUT doing this means we now have the problem you describe where a quote that contains only a citation can be incorrectly deleted:

quote-bug-3


Long story short, I think that patching Editable is unworkable. To fix this bug in the general case we need the merge/deletion logic to read the block's entire state.

I have two ideas:

  1. Go back to what we had before where each block defines an isEmpty method which the MERGE_BLOCKS effect can use to determine if that block should be deleted when being merged. I like the explicitness of this approach, but it does mean extra API surface.
  2. Same as (1) but instead of each block defining an isEmpty we build a method that compares each of the block's attributes against the block's default attributes. If no attribute differs from its default, then the block is empty and can be deleted when being merged. I think this might be our best bet.

@aduth
Copy link
Member

aduth commented Dec 11, 2017

Could we add a new callback onRemove to Editable which is called as in the current implementation where we delete an empty Editable? Then, the block could decide if onRemove should be handled as onReplace( [] ) (i.e. paragraph, or in other blocks when first checking that all other fields are empty as well).

@noisysocks noisysocks force-pushed the try/deleting-empty-blocks-when-backspacing-into-an-image branch from e2257f5 to 07e051e Compare December 13, 2017 00:45
@noisysocks
Copy link
Member Author

Could we add a new callback onRemove to Editable which is called as in the current implementation where we delete an empty Editable?

Nice suggestion. I've done this and it seems to work pretty well.

I also fixed a similar bug to what @gziolo found where if one presses backspace in an empty paragraph block that follows a list block, an empty <li> is created.

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.

This seems to work quite nicely.

event.preventDefault();
event.stopImmediatePropagation();

return;
Copy link
Member

Choose a reason for hiding this comment

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

Is the return; necessary?

}

const isEmpty = dom.isEmpty( rootNode );
if ( isEmpty && this.props.onRemove ) {
Copy link
Member

Choose a reason for hiding this comment

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

Minor: If dom.isEmpty is non-trivial (sorta appears so), we could achieve a minor optimization here with conditional short-circuiting by reordering the condition to test the truthy onRemove first:

if ( this.props.onRemove && dom.isEmpty( rootNode ) ) {

When backspacing from an empty paragraph into a block that has no merge
function (e.g. an image), the empty paragraph should be deleted.

Empty lists, headings and quotes are treated similarly.
Reduce the amount of times that empty <li>s are created when converting
a core/quote to a core/list.

The conversion logic is:

* 'Quote' / 'Citation' -> `<li>Quote</li><li>Citation</li>`
* '' / 'Citation' -> `<li></li><li>Citation</li>`
* 'Quote' / '' -> `<li>Quote</li>`
* '' / '' -> ``
Prevent empty paragraphs from being converted to an empty <li>.
@noisysocks noisysocks force-pushed the try/deleting-empty-blocks-when-backspacing-into-an-image branch from 07e051e to 18e281a Compare December 14, 2017 01:56
@noisysocks noisysocks merged commit e421572 into master Dec 14, 2017
@noisysocks noisysocks deleted the try/deleting-empty-blocks-when-backspacing-into-an-image branch December 14, 2017 02:02
@mtias
Copy link
Member

mtias commented Jan 10, 2018

Nice work here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Type] Bug An existing feature does not function as intended
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Backspace from empty paragraph into image doesn't delete paragraph
8 participants