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