diff --git a/src/delete.js b/src/delete.js
index 1a0b25f..7f84f12 100644
--- a/src/delete.js
+++ b/src/delete.js
@@ -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();
} );
diff --git a/src/deletecommand.js b/src/deletecommand.js
index a010a82..4b6274d 100644
--- a/src/deletecommand.js
+++ b/src/deletecommand.js
@@ -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';
@@ -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;
@@ -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;
@@ -99,4 +110,73 @@ export default class DeleteCommand extends Command {
this._buffer.unlock();
} );
}
+
+ /**
+ * If the user keeps Backspace or Delete 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 Ctrl + A, Backspace).
+ *
+ * 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 (
[]
)
+ // 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 );
+ }
}
diff --git a/src/deleteobserver.js b/src/deleteobserver.js
index 096430f..32e61dd 100644
--- a/src/deleteobserver.js
+++ b/src/deleteobserver.js
@@ -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 = {};
@@ -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 ) );
} );
@@ -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.
*/
diff --git a/tests/delete.js b/tests/delete.js
index b967b1a..691e3d2 100644
--- a/tests/delete.js
+++ b/tests/delete.js
@@ -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', () => {
diff --git a/tests/deletecommand.js b/tests/deletecommand.js
index 485e722..24ebe12 100644
--- a/tests/deletecommand.js
+++ b/tests/deletecommand.js
@@ -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' );
} );
} );
@@ -38,22 +39,22 @@ describe( 'DeleteCommand', () => {
describe( 'execute()', () => {
it( 'uses enqueueChanges', () => {
- setData( doc, 'foo[]bar
' );
+ setData( doc, 'foo[]bar' );
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( 'foo[]bar
' );
+ expect( getData( doc ) ).to.equal( 'foo[]bar' );
} );
// After all enqueued changes are done, the command execution is reflected.
- expect( getData( doc ) ).to.equal( 'fo[]bar
' );
+ expect( getData( doc ) ).to.equal( 'fo[]bar' );
} );
it( 'locks buffer when executing', () => {
- setData( doc, 'foo[]bar
' );
+ setData( doc, 'foo[]bar' );
const buffer = editor.commands.get( 'delete' )._buffer;
const lockSpy = testUtils.sinon.spy( buffer, 'lock' );
@@ -66,38 +67,38 @@ describe( 'DeleteCommand', () => {
} );
it( 'deletes previous character when selection is collapsed', () => {
- setData( doc, 'foo[]bar
' );
+ setData( doc, 'foo[]bar' );
editor.execute( 'delete' );
- expect( getData( doc, { selection: true } ) ).to.equal( 'fo[]bar
' );
+ expect( getData( doc ) ).to.equal( 'fo[]bar' );
} );
it( 'deletes selection contents', () => {
- setData( doc, 'fo[ob]ar
' );
+ setData( doc, 'fo[ob]ar' );
editor.execute( 'delete' );
- expect( getData( doc, { selection: true } ) ).to.equal( 'fo[]ar
' );
+ expect( getData( doc ) ).to.equal( 'fo[]ar' );
} );
it( 'merges elements', () => {
- setData( doc, 'foo
[]bar
' );
+ setData( doc, 'foo[]bar' );
editor.execute( 'delete' );
- expect( getData( doc, { selection: true } ) ).to.equal( 'foo[]bar
' );
+ expect( getData( doc ) ).to.equal( 'foo[]bar' );
} );
it( 'does not try to delete when selection is at the boundary', () => {
const spy = sinon.spy();
editor.data.on( 'deleteContent', spy );
- setData( doc, '[]foo
' );
+ setData( doc, '[]foo' );
editor.execute( 'delete' );
- expect( getData( doc, { selection: true } ) ).to.equal( '[]foo
' );
+ expect( getData( doc ) ).to.equal( '[]foo' );
expect( spy.callCount ).to.equal( 0 );
} );
@@ -105,7 +106,7 @@ describe( 'DeleteCommand', () => {
const spy = sinon.spy();
editor.data.on( 'modifySelection', spy );
- setData( doc, 'foo[]bar
' );
+ setData( doc, 'foo[]bar' );
editor.commands.get( 'delete' ).direction = 'forward';
@@ -122,7 +123,7 @@ describe( 'DeleteCommand', () => {
const spy = sinon.spy();
editor.data.on( 'deleteContent', spy );
- setData( doc, 'foo[]bar
' );
+ setData( doc, 'foo[]bar' );
editor.execute( 'delete' );
@@ -136,7 +137,7 @@ describe( 'DeleteCommand', () => {
const spy = sinon.spy();
editor.data.on( 'deleteContent', spy );
- setData( doc, '[foobar]
' );
+ setData( doc, '[foobar]' );
editor.execute( 'delete' );
@@ -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, '[Header 1Some text.]' );
+
+ editor.execute( 'delete' );
+
+ expect( getData( doc ) ).to.equal( '[]' );
+ } );
+
+ 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,
+ 'Foo' +
+ '' +
+ '[Header 1' +
+ 'Some text.]' +
+ '' +
+ 'Bar.'
+ );
+
+ editor.execute( 'delete' );
+
+ expect( getData( doc ) ).to.equal(
+ 'Foo' +
+ '' +
+ 'Bar.'
+ );
+ } );
+
+ 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, '' );
+
+ editor.execute( 'delete' );
+
+ expect( getData( doc ) ).to.equal( '' );
+ } );
+
+ 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, '[]' );
+
+ editor.execute( 'delete' );
+
+ expect( getData( doc ) ).to.equal( '[]' );
+ } );
+
+ it( 'replaces an empty element with paragraph', () => {
+ setData( doc, '[]' );
+
+ editor.execute( 'delete' );
+
+ expect( getData( doc ) ).to.equal( '[]' );
+ } );
+
+ it( 'does not replace an element when Backspace or Delete key is held', () => {
+ setData( doc, 'Bar[]' );
+
+ for ( let sequence = 1; sequence < 10; ++sequence ) {
+ editor.execute( 'delete', { sequence } );
+ }
+
+ expect( getData( doc ) ).to.equal( '[]' );
+ } );
+
+ it( 'does not replace with paragraph in another paragraph already occurs in limit element', () => {
+ setData( doc, '[]' );
+
+ 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, '[]' );
+
+ editor.execute( 'delete' );
+
+ expect( getData( doc ) ).to.equal( '[]' );
+ } );
} );
} );
diff --git a/tests/deleteobserver.js b/tests/deleteobserver.js
index ad32de4..3b76c07 100644
--- a/tests/deleteobserver.js
+++ b/tests/deleteobserver.js
@@ -40,6 +40,7 @@ describe( 'DeleteObserver', () => {
const data = spy.args[ 0 ][ 1 ];
expect( data ).to.have.property( 'direction', 'forward' );
expect( data ).to.have.property( 'unit', 'character' );
+ expect( data ).to.have.property( 'sequence', 1 );
} );
it( 'is fired with a proper direction and unit', () => {
@@ -57,6 +58,7 @@ describe( 'DeleteObserver', () => {
const data = spy.args[ 0 ][ 1 ];
expect( data ).to.have.property( 'direction', 'backward' );
expect( data ).to.have.property( 'unit', 'word' );
+ expect( data ).to.have.property( 'sequence', 1 );
} );
it( 'is not fired on keydown when keyCode does not match backspace or delete', () => {
@@ -70,6 +72,101 @@ describe( 'DeleteObserver', () => {
expect( spy.calledOnce ).to.be.false;
} );
+
+ it( 'is fired with a proper sequence number', () => {
+ const spy = sinon.spy();
+
+ viewDocument.on( 'delete', spy );
+
+ // Simulate that a user keeps the "Delete" key.
+ for ( let i = 0; i < 5; ++i ) {
+ viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+ keyCode: getCode( 'delete' )
+ } ) );
+ }
+
+ expect( spy.callCount ).to.equal( 5 );
+
+ expect( spy.args[ 0 ][ 1 ] ).to.have.property( 'sequence', 1 );
+ expect( spy.args[ 1 ][ 1 ] ).to.have.property( 'sequence', 2 );
+ expect( spy.args[ 2 ][ 1 ] ).to.have.property( 'sequence', 3 );
+ expect( spy.args[ 3 ][ 1 ] ).to.have.property( 'sequence', 4 );
+ expect( spy.args[ 4 ][ 1 ] ).to.have.property( 'sequence', 5 );
+ } );
+
+ it( 'clears the sequence when the key was released', () => {
+ const spy = sinon.spy();
+
+ viewDocument.on( 'delete', spy );
+
+ // Simulate that a user keeps the "Delete" key.
+ for ( let i = 0; i < 3; ++i ) {
+ viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+ keyCode: getCode( 'delete' )
+ } ) );
+ }
+
+ // Then the user has released the key.
+ viewDocument.fire( 'keyup', new DomEventData( viewDocument, getDomEvent(), {
+ keyCode: getCode( 'delete' )
+ } ) );
+
+ // And pressed it once again.
+ viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+ keyCode: getCode( 'delete' )
+ } ) );
+
+ expect( spy.callCount ).to.equal( 4 );
+
+ expect( spy.args[ 0 ][ 1 ] ).to.have.property( 'sequence', 1 );
+ expect( spy.args[ 1 ][ 1 ] ).to.have.property( 'sequence', 2 );
+ expect( spy.args[ 2 ][ 1 ] ).to.have.property( 'sequence', 3 );
+ expect( spy.args[ 3 ][ 1 ] ).to.have.property( 'sequence', 1 );
+ } );
+
+ it( 'works fine with Backspace key', () => {
+ const spy = sinon.spy();
+
+ viewDocument.on( 'delete', spy );
+
+ viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+ keyCode: getCode( 'backspace' )
+ } ) );
+
+ viewDocument.fire( 'keyup', new DomEventData( viewDocument, getDomEvent(), {
+ keyCode: getCode( 'backspace' )
+ } ) );
+
+ viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+ keyCode: getCode( 'backspace' )
+ } ) );
+
+ expect( spy.callCount ).to.equal( 2 );
+
+ expect( spy.args[ 0 ][ 1 ] ).to.have.property( 'sequence', 1 );
+ expect( spy.args[ 1 ][ 1 ] ).to.have.property( 'sequence', 1 );
+ } );
+
+ it( 'does not reset the sequence if other than Backspace or Delete key was released', () => {
+ const spy = sinon.spy();
+
+ viewDocument.on( 'delete', spy );
+
+ viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+ keyCode: getCode( 'delete' )
+ } ) );
+
+ viewDocument.fire( 'keyup', new DomEventData( viewDocument, getDomEvent(), {
+ keyCode: getCode( 'A' )
+ } ) );
+
+ viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+ keyCode: getCode( 'delete' )
+ } ) );
+
+ expect( spy.args[ 0 ][ 1 ] ).to.have.property( 'sequence', 1 );
+ expect( spy.args[ 1 ][ 1 ] ).to.have.property( 'sequence', 2 );
+ } );
} );
function getDomEvent() {
diff --git a/tests/manual/delete.md b/tests/manual/delete.md
index 2f48fb9..b9933ab 100644
--- a/tests/manual/delete.md
+++ b/tests/manual/delete.md
@@ -3,4 +3,8 @@
Check:
* collapsed selection (by letter, by word, whole line),
-* non-collapsed selections.
+* non-collapsed selections,
+* put the selection at the end of **Heading 1**, **press and hold** the Backspace.
+After releasing the key you should be able typing inside the header.
+* clear the whole editor and choose **Heading 1** from the dropdown. Press the Backspace.
+After typing, your text should be wrapped in a paragraph.
diff --git a/tests/manual/input.md b/tests/manual/input.md
index c1d9adf..9f65f20 100644
--- a/tests/manual/input.md
+++ b/tests/manual/input.md
@@ -3,7 +3,8 @@
Check:
* normal typing,
-* typing into non-collapsed selection.
+* typing into non-collapsed selection,
+* typing when the entire content is selected - new content should be wrapped in a paragraph.
### IME