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

RichText: Fix empty value delete behaviors #8735

Merged
merged 4 commits into from
Aug 9, 2018
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
31 changes: 20 additions & 11 deletions packages/editor/src/components/block-list/block.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,22 +218,31 @@ export class BlockListBlock extends Component {
}
}

/**
* Handles block merger, returning true if the merge is possible, or false
* otherwise.
*
* @param {boolean} forward Whether merge should occur with the next block.
*
* @return {boolean} Whether merge is possible.
*/
mergeBlocks( forward = false ) {
const { block, previousBlockClientId, nextBlockClientId, onMerge } = this.props;

// Do nothing when it's the first block.
if (
( ! forward && ! previousBlockClientId ) ||
( forward && ! nextBlockClientId )
) {
return;
}
const hasMergeableTarget = (
( forward && !! nextBlockClientId ) ||
( ! forward && !! previousBlockClientId )
);

if ( forward ) {
onMerge( block.clientId, nextBlockClientId );
} else {
onMerge( previousBlockClientId, block.clientId );
if ( hasMergeableTarget ) {
if ( forward ) {
onMerge( block.clientId, nextBlockClientId );
} else {
onMerge( previousBlockClientId, block.clientId );
}
}

return hasMergeableTarget;
}

insertBlocksAfter( blocks ) {
Expand Down
4 changes: 2 additions & 2 deletions packages/editor/src/components/rich-text/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ Render a rich [`contenteditable` input](https://developer.mozilla.org/en-US/docs

### `onMerge( forward: Boolean ): Function`

*Optional.* Called when blocks can be merged. `forward` is true when merging with the next block, false when merging with the previous block.
*Optional.* Called when blocks can be merged. `forward` is true when merging with the next block, false when merging with the previous block. The callback should return `true` if a merge is possible, or `false` otherwise. This is used in determining whether an empty field should be deleted instead of merged.
Copy link
Member

Choose a reason for hiding this comment

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

Just a drive-by review nitpick: "true" should be backtick-wrapped (eg "true") for improved readability (it's done that way elsewhere).

Copy link
Member Author

Choose a reason for hiding this comment

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

Changes backed out in rebased d29d5d1.


### `onRemove( forward: Boolean ): Function`

*Optional.* Called when the block can be removed. `forward` is true when the selection is expected to move to the next block, false to the previous block.
*Optional.* Called when the block can be removed. `forward` is true when the selection is expected to move to the next block, false to the previous block. Called when pressing Backspace or Delete on an empty field and only when `onMerge` explicitly returns `false` to indicate non-merge.
Copy link
Member

Choose a reason for hiding this comment

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

Same here re: true and false.

Copy link
Member Author

Choose a reason for hiding this comment

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

Changes backed out in rebased d29d5d1.


### `formattingControls: Array`

Expand Down
96 changes: 70 additions & 26 deletions packages/editor/src/components/rich-text/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export class RichText extends Component {
this.onFocus = this.onFocus.bind( this );
this.onChange = this.onChange.bind( this );
this.onNodeChange = this.onNodeChange.bind( this );
this.onDeleteKeyDown = this.onDeleteKeyDown.bind( this );
this.onHorizontalNavigationKeyDown = this.onHorizontalNavigationKeyDown.bind( this );
this.onKeyDown = this.onKeyDown.bind( this );
this.onKeyUp = this.onKeyUp.bind( this );
Expand Down Expand Up @@ -426,41 +427,84 @@ export class RichText extends Component {
}

/**
* Handles a keydown event from TinyMCE.
* Handles a delete key down event to handle merge or removal for a
Copy link
Member

Choose a reason for hiding this comment

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

It might be easier to visually scan for keyDown over key down.

Copy link
Member Author

Choose a reason for hiding this comment

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

Updated in rebased d29d5d1.

* collapsed selection where caret is at directional edge: forward for a
Copy link
Member

Choose a reason for hiding this comment

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

An article (eg "the caret") here might improve readability.

Copy link
Member Author

Choose a reason for hiding this comment

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

Added link in rebased d29d5d1.

* delete key down, reverse for a backspace key down.
*
* @param {KeydownEvent} event The keydown event as triggered by TinyMCE.
* @param {tinymce.EditorEvent<KeyboardEvent>} event Keydown event.
*/
onKeyDown( event ) {
const dom = this.editor.dom;
const rootNode = this.editor.getBody();
onDeleteKeyDown( event ) {
const { onMerge, onRemove } = this.props;
if ( ! onMerge && ! onRemove ) {
return;
}

const { keyCode } = event;
const isReverse = keyCode === BACKSPACE;
const { isCollapsed } = getSelection();

if (
getSelection().isCollapsed && (
( keyCode === BACKSPACE && isHorizontalEdge( rootNode, true ) ) ||
( keyCode === DELETE && isHorizontalEdge( rootNode, false ) )
)
) {
if ( ! this.props.onMerge && ! this.props.onRemove ) {
return;
}
// Only process delete if occurs at uncollapsed edge.
Copy link
Member

Choose a reason for hiding this comment

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

Sorry to nitpick but this comment is a bit unclear... if what occurs? I guess this should be something like:

// Only process delete even if the deletion occurs at an uncollapsed edge.

Copy link
Member Author

Choose a reason for hiding this comment

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

Updated in rebased d29d5d1.

if ( ! isCollapsed ) {
return;
}

const forward = keyCode === DELETE;
// It is important to consider emptiness because an empty container
// will include a bogus TinyMCE BR node _after_ the caret, so in a
// forward deletion the isHorizontalEdge function will incorrectly
// interpret the presence of the bogus node as not being at the edge.
const isEmpty = this.isEmpty();
const isEdge = (
isEmpty ||
isHorizontalEdge( this.editor.getBody(), isReverse )
);

if ( this.props.onMerge ) {
this.props.onMerge( forward );
}
if ( ! isEdge ) {
return;
}

if ( this.props.onRemove && this.isEmpty() ) {
this.props.onRemove( forward );
}
let isHandled = false;

event.preventDefault();
let isMerged = false;
if ( onMerge ) {
isMerged = onMerge( ! isReverse );
isHandled = !! isMerged;
}

// `onMerge`, if it is exists, is not required to return a value, thus
// the check on explicit false. This condition will cover cases where
// either (a) `onMerge` is not defined or (b) it returns false to
// indicate a merge was not possible.
if ( onRemove && false === isMerged && isEmpty ) {
onRemove( ! isReverse );
isHandled = true;
}

if ( ! isHandled ) {
// Only prevent default behaviors if handled.
return;
}

event.preventDefault();

// Calling onMerge() or onRemove() will destroy the editor, so it's
// important that we stop other handlers (e.g. ones registered by
// TinyMCE) from also handling this event.
event.stopImmediatePropagation();
}

/**
* Handles a keydown event from TinyMCE.
*
* @param {KeydownEvent} event The keydown event as triggered by TinyMCE.
*/
onKeyDown( event ) {
const dom = this.editor.dom;
const rootNode = this.editor.getBody();
const { keyCode } = event;

// Calling onMerge() or onRemove() will destroy the editor, so it's important
// that we stop other handlers (e.g. ones registered by TinyMCE) from
// also handling this event.
event.stopImmediatePropagation();
const isDelete = keyCode === DELETE || keyCode === BACKSPACE;
if ( isDelete ) {
this.onDeleteKeyDown( event );
}

const isHorizontalNavigation = keyCode === LEFT || keyCode === RIGHT;
Expand Down
8 changes: 8 additions & 0 deletions test/e2e/specs/__snapshots__/splitting-merging.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ exports[`splitting and merging blocks should delete an empty first line 1`] = `
<!-- /wp:paragraph -->"
`;

exports[`splitting and merging blocks should forward delete from an empty paragraph 1`] = `
"<!-- wp:paragraph -->
<p></p>
<!-- /wp:paragraph -->"
`;

exports[`splitting and merging blocks should gracefully handle if placing caret in empty container 1`] = `
"<!-- wp:paragraph -->
<p><strong>Foo</strong></p>
Expand All @@ -32,6 +38,8 @@ exports[`splitting and merging blocks should not merge paragraphs if the selecti
<!-- /wp:paragraph -->"
`;

exports[`splitting and merging blocks should remove empty paragraph block on backspace 1`] = `""`;

exports[`splitting and merging blocks should split and merge paragraph blocks using Enter and Backspace 1`] = `
"<!-- wp:paragraph -->
<p>First</p>
Expand Down
32 changes: 31 additions & 1 deletion test/e2e/specs/splitting-merging.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,22 @@ describe( 'splitting and merging blocks', () => {
} );

it( 'should not merge paragraphs if the selection is not collapsed', async () => {
// Regression Test: When all of a paragraph is selected, pressing
// backspace should delete the contents, not merge to previous.
//
// See: https://github.com/WordPress/gutenberg/issues/8268
await insertBlock( 'Paragraph' );
await page.keyboard.type( 'Foo' );
await insertBlock( 'Paragraph' );
await page.keyboard.type( 'Bar' );

// Select text.
await page.keyboard.down( 'Shift' );
await pressTimes( 'ArrowLeft', 3 );
await page.keyboard.up( 'Shift' );
await page.keyboard.press( 'Backspace' );

// Delete selection.
await page.keyboard.press( 'Backspace' );
expect( await getEditedPostContent() ).toMatchSnapshot();
} );

Expand All @@ -121,4 +127,28 @@ describe( 'splitting and merging blocks', () => {

expect( await getEditedPostContent() ).toMatchSnapshot();
} );

it( 'should forward delete from an empty paragraph', async () => {
// Regression test: Bogus nodes in a TinyMCE container can interfere
// with isHorizontalEdge detection, preventing forward deletion.
//
// See: https://github.com/WordPress/gutenberg/issues/8731
await insertBlock( 'Paragraph' );
await page.keyboard.press( 'Enter' );
await page.keyboard.press( 'ArrowUp' );
await page.keyboard.press( 'Delete' );

expect( await getEditedPostContent() ).toMatchSnapshot();
} );

it( 'should remove empty paragraph block on backspace', async () => {
// Regression Test: In a sole empty paragraph, pressing backspace
// should remove the block.
//
// See: https://github.com/WordPress/gutenberg/pull/8306
await insertBlock( 'Paragraph' );
await page.keyboard.press( 'Backspace' );

expect( await getEditedPostContent() ).toMatchSnapshot();
} );
} );
30 changes: 30 additions & 0 deletions test/e2e/support/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,34 @@ async function login() {
] );
}

/**
* Returns a promise which resolves once it's determined that the active DOM
Copy link
Member

Choose a reason for hiding this comment

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

Thanks, this write-up is great 👍

* element is not within a RichText field, or the RichText field's TinyMCE has
* completed initialization. This is an unfortunate workaround to address an
* issue where TinyMCE takes its time to become ready for user input.
*
* TODO: This is a code smell, indicating that "too fast" resulting in breakage
* could be equally problematic for a fast human. It should be explored whether
* all event bindings we assign to TinyMCE to handle could be handled through
* the DOM directly instead.
*
* @return {Promise} Promise resolving once RichText is initialized, or is
* determined to not be a container of the active element.
*/
async function waitForRichTextInitialization() {
const isInRichText = await page.evaluate( () => {
return !! document.activeElement.closest( '.editor-rich-text__tinymce' );
} );

if ( ! isInRichText ) {
return;
}

return page.waitForFunction( () => {
return !! document.activeElement.closest( '.mce-content-body' );
} );
}

export async function visitAdmin( adminPath, query ) {
await goToWPPath( join( 'wp-admin', adminPath ), query );

Expand Down Expand Up @@ -170,6 +198,7 @@ export async function ensureSidebarOpened() {
*/
export async function clickBlockAppender() {
await expect( page ).toClick( '.editor-default-block-appender__content' );
await waitForRichTextInitialization();
}

/**
Expand Down Expand Up @@ -199,6 +228,7 @@ export async function insertBlock( searchTerm, panelName = null ) {
await panelButton.click();
}
await page.click( `button[aria-label="${ searchTerm }"]` );
await waitForRichTextInitialization();
}

/**
Expand Down