Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

Commit

Permalink
Merge pull request #111 from ckeditor/t/61
Browse files Browse the repository at this point in the history
Feature: Pressing <kbd>Backspace</kbd> or <kbd>Delete</kbd> in an empty content will reset the current block to a paragraph. Closes #61.
  • Loading branch information
Reinmar authored Aug 22, 2017
2 parents cf3ca86 + 9f57e6f commit bb07bc6
Show file tree
Hide file tree
Showing 8 changed files with 318 additions and 25 deletions.
2 changes: 1 addition & 1 deletion src/delete.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export default class Delete extends Plugin {
editor.commands.add( 'delete', new DeleteCommand( editor, 'backward' ) );

this.listenTo( editingView, 'delete', ( evt, data ) => {
editor.execute( data.direction == 'forward' ? 'forwardDelete' : 'delete', { unit: data.unit } );
editor.execute( data.direction == 'forward' ? 'forwardDelete' : 'delete', { unit: data.unit, sequence: data.sequence } );
data.preventDefault();
editingView.scrollToTheSelection();
} );
Expand Down
84 changes: 82 additions & 2 deletions src/deletecommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@

import Command from '@ckeditor/ckeditor5-core/src/command';
import Selection from '@ckeditor/ckeditor5-engine/src/model/selection';
import Element from '@ckeditor/ckeditor5-engine/src/model/element';
import Position from '@ckeditor/ckeditor5-engine/src/model/position';
import Range from '@ckeditor/ckeditor5-engine/src/model/range';
import ChangeBuffer from './changebuffer';
import count from '@ckeditor/ckeditor5-utils/src/count';

Expand Down Expand Up @@ -54,8 +57,9 @@ export default class DeleteCommand extends Command {
*
* @fires execute
* @param {Object} [options] The command options.
* @param {'character'} [options.unit='character'] See {@link module:engine/controller/modifyselection~modifySelection}'s
* options.
* @param {'character'} [options.unit='character'] See {@link module:engine/controller/modifyselection~modifySelection}'s options.
* @param {Number} [options.sequence=1] A number describing which subsequent delete event it is without the key being released.
* See the {@link module:engine/view/document~Document#event:delete} event data.
*/
execute( options = {} ) {
const doc = this.editor.document;
Expand All @@ -78,6 +82,13 @@ export default class DeleteCommand extends Command {
dataController.modifySelection( selection, { direction: this.direction, unit: options.unit } );
}

// Check if deleting in an empty editor. See #61.
if ( this._shouldEntireContentBeReplacedWithParagraph( options.sequence || 1 ) ) {
this._replaceEntireContentWithParagraph();

return;
}

// If selection is still collapsed, then there's nothing to delete.
if ( selection.isCollapsed ) {
return;
Expand All @@ -99,4 +110,73 @@ export default class DeleteCommand extends Command {
this._buffer.unlock();
} );
}

/**
* If the user keeps <kbd>Backspace</kbd> or <kbd>Delete</kbd> key pressed, the content of the current
* editable will be cleared. However, this will not yet lead to resetting the remaining block to a paragraph
* (which happens e.g. when the user does <kbd>Ctrl</kbd> + <kbd>A</kbd>, <kbd>Backspace</kbd>).
*
* But, if the user pressed the key in an empty editable for the first time,
* we want to replace the entire content with a paragraph if:
*
* * the current limit element is empty,
* * the paragraph is allowed in the limit element,
* * the limit doesn't already have a paragraph inside.
*
* See https://github.com/ckeditor/ckeditor5-typing/issues/61.
*
* @private
* @param {Number} sequence A number describing which subsequent delete event it is without the key being released.
* @returns {Boolean}
*/
_shouldEntireContentBeReplacedWithParagraph( sequence ) {
// Does nothing if user pressed and held the "Backspace" or "Delete" key.
if ( sequence > 1 ) {
return false;
}

const document = this.editor.document;
const selection = document.selection;
const limitElement = document.schema.getLimitElement( selection );

// If a collapsed selection contains the whole content it means that the content is empty
// (from the user perspective).
const limitElementIsEmpty = selection.isCollapsed && selection.containsEntireContent( limitElement );

if ( !limitElementIsEmpty ) {
return false;
}

if ( !document.schema.check( { name: 'paragraph', inside: limitElement.name } ) ) {
return false;
}

const limitElementFirstChild = limitElement.getChild( 0 );

// Does nothing if the limit element already contains only a paragraph.
// We ignore the case when paragraph might have some inline elements (<p><inlineWidget>[]</inlineWidget></p>)
// because we don't support such cases yet and it's unclear whether inlineWidget shouldn't be a limit itself.
if ( limitElementFirstChild && limitElementFirstChild.name === 'paragraph' ) {
return false;
}

return true;
}

/**
* The entire content is replaced with the paragraph. Selection is moved inside the paragraph.
*
* @private
*/
_replaceEntireContentWithParagraph() {
const document = this.editor.document;
const selection = document.selection;
const limitElement = document.schema.getLimitElement( selection );
const paragraph = new Element( 'paragraph' );

this._buffer.batch.remove( Range.createIn( limitElement ) );
this._buffer.batch.insert( Position.createAt( limitElement ), paragraph );

selection.setCollapsedAt( paragraph );
}
}
11 changes: 11 additions & 0 deletions src/deleteobserver.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ export default class DeleteObserver extends Observer {
constructor( document ) {
super( document );

let sequence = 0;

document.on( 'keyup', ( evt, data ) => {
if ( data.keyCode == keyCodes.delete || data.keyCode == keyCodes.backspace ) {
sequence = 0;
}
} );

document.on( 'keydown', ( evt, data ) => {
const deleteData = {};

Expand All @@ -34,6 +42,7 @@ export default class DeleteObserver extends Observer {
}

deleteData.unit = data.altKey ? 'word' : deleteData.unit;
deleteData.sequence = ++sequence;

document.fire( 'delete', new DomEventData( document, data.domEvent, deleteData ) );
} );
Expand All @@ -55,4 +64,6 @@ export default class DeleteObserver extends Observer {
* @param {module:engine/view/observer/domeventdata~DomEventData} data
* @param {'forward'|'delete'} data.direction The direction in which the deletion should happen.
* @param {'character'|'word'} data.unit The "amount" of content that should be deleted.
* @param {Number} data.sequence A number describing which subsequent delete event it is without the key being released.
* If it's 2 or more it means that the key was pressed and hold.
*/
10 changes: 6 additions & 4 deletions tests/delete.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,23 @@ describe( 'Delete feature', () => {

view.fire( 'delete', new DomEventData( editingView, domEvt, {
direction: 'forward',
unit: 'character'
unit: 'character',
sequence: 1
} ) );

expect( spy.calledOnce ).to.be.true;
expect( spy.calledWithMatch( 'forwardDelete', { unit: 'character' } ) ).to.be.true;
expect( spy.calledWithMatch( 'forwardDelete', { unit: 'character', sequence: 1 } ) ).to.be.true;

expect( domEvt.preventDefault.calledOnce ).to.be.true;

view.fire( 'delete', new DomEventData( editingView, getDomEvent(), {
direction: 'backward',
unit: 'character'
unit: 'character',
sequence: 5
} ) );

expect( spy.calledTwice ).to.be.true;
expect( spy.calledWithMatch( 'delete', { unit: 'character' } ) ).to.be.true;
expect( spy.calledWithMatch( 'delete', { unit: 'character', sequence: 5 } ) ).to.be.true;
} );

it( 'scrolls the editing document to the selection after executing the command', () => {
Expand Down
130 changes: 114 additions & 16 deletions tests/deletecommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ describe( 'DeleteCommand', () => {
const command = new DeleteCommand( editor, 'backward' );
editor.commands.add( 'delete', command );

doc.schema.registerItem( 'p', '$block' );
doc.schema.registerItem( 'paragraph', '$block' );
doc.schema.registerItem( 'heading1', '$block' );
} );
} );

Expand All @@ -38,22 +39,22 @@ describe( 'DeleteCommand', () => {

describe( 'execute()', () => {
it( 'uses enqueueChanges', () => {
setData( doc, '<p>foo[]bar</p>' );
setData( doc, '<paragraph>foo[]bar</paragraph>' );

doc.enqueueChanges( () => {
editor.execute( 'delete' );

// We expect that command is executed in enqueue changes block. Since we are already in
// an enqueued block, the command execution will be postponed. Hence, no changes.
expect( getData( doc ) ).to.equal( '<p>foo[]bar</p>' );
expect( getData( doc ) ).to.equal( '<paragraph>foo[]bar</paragraph>' );
} );

// After all enqueued changes are done, the command execution is reflected.
expect( getData( doc ) ).to.equal( '<p>fo[]bar</p>' );
expect( getData( doc ) ).to.equal( '<paragraph>fo[]bar</paragraph>' );
} );

it( 'locks buffer when executing', () => {
setData( doc, '<p>foo[]bar</p>' );
setData( doc, '<paragraph>foo[]bar</paragraph>' );

const buffer = editor.commands.get( 'delete' )._buffer;
const lockSpy = testUtils.sinon.spy( buffer, 'lock' );
Expand All @@ -66,46 +67,46 @@ describe( 'DeleteCommand', () => {
} );

it( 'deletes previous character when selection is collapsed', () => {
setData( doc, '<p>foo[]bar</p>' );
setData( doc, '<paragraph>foo[]bar</paragraph>' );

editor.execute( 'delete' );

expect( getData( doc, { selection: true } ) ).to.equal( '<p>fo[]bar</p>' );
expect( getData( doc ) ).to.equal( '<paragraph>fo[]bar</paragraph>' );
} );

it( 'deletes selection contents', () => {
setData( doc, '<p>fo[ob]ar</p>' );
setData( doc, '<paragraph>fo[ob]ar</paragraph>' );

editor.execute( 'delete' );

expect( getData( doc, { selection: true } ) ).to.equal( '<p>fo[]ar</p>' );
expect( getData( doc ) ).to.equal( '<paragraph>fo[]ar</paragraph>' );
} );

it( 'merges elements', () => {
setData( doc, '<p>foo</p><p>[]bar</p>' );
setData( doc, '<paragraph>foo</paragraph><paragraph>[]bar</paragraph>' );

editor.execute( 'delete' );

expect( getData( doc, { selection: true } ) ).to.equal( '<p>foo[]bar</p>' );
expect( getData( doc ) ).to.equal( '<paragraph>foo[]bar</paragraph>' );
} );

it( 'does not try to delete when selection is at the boundary', () => {
const spy = sinon.spy();

editor.data.on( 'deleteContent', spy );
setData( doc, '<p>[]foo</p>' );
setData( doc, '<paragraph>[]foo</paragraph>' );

editor.execute( 'delete' );

expect( getData( doc, { selection: true } ) ).to.equal( '<p>[]foo</p>' );
expect( getData( doc ) ).to.equal( '<paragraph>[]foo</paragraph>' );
expect( spy.callCount ).to.equal( 0 );
} );

it( 'passes options to modifySelection', () => {
const spy = sinon.spy();

editor.data.on( 'modifySelection', spy );
setData( doc, '<p>foo[]bar</p>' );
setData( doc, '<paragraph>foo[]bar</paragraph>' );

editor.commands.get( 'delete' ).direction = 'forward';

Expand All @@ -122,7 +123,7 @@ describe( 'DeleteCommand', () => {
const spy = sinon.spy();

editor.data.on( 'deleteContent', spy );
setData( doc, '<p>foo[]bar</p>' );
setData( doc, '<paragraph>foo[]bar</paragraph>' );

editor.execute( 'delete' );

Expand All @@ -136,7 +137,7 @@ describe( 'DeleteCommand', () => {
const spy = sinon.spy();

editor.data.on( 'deleteContent', spy );
setData( doc, '<p>[foobar]</p>' );
setData( doc, '<paragraph>[foobar]</paragraph>' );

editor.execute( 'delete' );

Expand All @@ -145,5 +146,102 @@ describe( 'DeleteCommand', () => {
const deleteOpts = spy.args[ 0 ][ 1 ][ 2 ];
expect( deleteOpts ).to.have.property( 'doNotResetEntireContent', false );
} );

it( 'leaves an empty paragraph after removing the whole content from editor', () => {
setData( doc, '<heading1>[Header 1</heading1><paragraph>Some text.]</paragraph>' );

editor.execute( 'delete' );

expect( getData( doc ) ).to.equal( '<paragraph>[]</paragraph>' );
} );

it( 'leaves an empty paragraph after removing the whole content inside limit element', () => {
doc.schema.registerItem( 'section', '$root' );
doc.schema.limits.add( 'section' );
doc.schema.allow( { name: 'section', inside: '$root' } );

setData( doc,
'<heading1>Foo</heading1>' +
'<section>' +
'<heading1>[Header 1</heading1>' +
'<paragraph>Some text.]</paragraph>' +
'</section>' +
'<paragraph>Bar.</paragraph>'
);

editor.execute( 'delete' );

expect( getData( doc ) ).to.equal(
'<heading1>Foo</heading1>' +
'<section>' +
'<paragraph>[]</paragraph>' +
'</section>' +
'<paragraph>Bar.</paragraph>'
);
} );

it( 'leaves an empty paragraph after removing another paragraph from block element', () => {
doc.schema.registerItem( 'section', '$block' );
doc.schema.registerItem( 'blockQuote', '$block' );
doc.schema.limits.add( 'section' );
doc.schema.allow( { name: 'section', inside: '$root' } );
doc.schema.allow( { name: 'paragraph', inside: 'section' } );
doc.schema.allow( { name: 'blockQuote', inside: 'section' } );
doc.schema.allow( { name: 'paragraph', inside: 'blockQuote' } );

setData( doc, '<section><blockQuote><paragraph>[]</paragraph></blockQuote></section>' );

editor.execute( 'delete' );

expect( getData( doc ) ).to.equal( '<section><paragraph>[]</paragraph></section>' );
} );

it( 'leaves an empty paragraph after removing the whole content when root element was not added as Schema.limits', () => {
doc.schema.limits.delete( '$root' );

setData( doc, '<heading1>[]</heading1>' );

editor.execute( 'delete' );

expect( getData( doc ) ).to.equal( '<paragraph>[]</paragraph>' );
} );

it( 'replaces an empty element with paragraph', () => {
setData( doc, '<heading1>[]</heading1>' );

editor.execute( 'delete' );

expect( getData( doc ) ).to.equal( '<paragraph>[]</paragraph>' );
} );

it( 'does not replace an element when Backspace or Delete key is held', () => {
setData( doc, '<heading1>Bar[]</heading1>' );

for ( let sequence = 1; sequence < 10; ++sequence ) {
editor.execute( 'delete', { sequence } );
}

expect( getData( doc ) ).to.equal( '<heading1>[]</heading1>' );
} );

it( 'does not replace with paragraph in another paragraph already occurs in limit element', () => {
setData( doc, '<paragraph>[]</paragraph>' );

const element = doc.getRoot().getNodeByPath( [ 0 ] );

editor.execute( 'delete' );

expect( element ).is.equal( doc.getRoot().getNodeByPath( [ 0 ] ) );
} );

it( 'does not replace an element if a paragraph is not allowed in current position', () => {
doc.schema.disallow( { name: 'paragraph', inside: '$root' } );

setData( doc, '<heading1>[]</heading1>' );

editor.execute( 'delete' );

expect( getData( doc ) ).to.equal( '<heading1>[]</heading1>' );
} );
} );
} );
Loading

0 comments on commit bb07bc6

Please sign in to comment.