Explorar o código

Merge pull request #111 from ckeditor/t/61

Feature: Pressing <kbd>Backspace</kbd> or <kbd>Delete</kbd> in an empty content will reset the current block to a paragraph. Closes #61.
Piotrek Koszuliński %!s(int64=8) %!d(string=hai) anos
pai
achega
cd4cb26ed7

+ 1 - 1
packages/ckeditor5-typing/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();
 		} );

+ 82 - 2
packages/ckeditor5-typing/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 <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 - 0
packages/ckeditor5-typing/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.
  */

+ 6 - 4
packages/ckeditor5-typing/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', () => {

+ 114 - 16
packages/ckeditor5-typing/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, '<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' );
@@ -66,38 +67,38 @@ 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 );
 		} );
 
@@ -105,7 +106,7 @@ describe( 'DeleteCommand', () => {
 			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';
 
@@ -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' );
 
@@ -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' );
 
@@ -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>' );
+		} );
 	} );
 } );

+ 97 - 0
packages/ckeditor5-typing/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() {

+ 5 - 1
packages/ckeditor5-typing/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.

+ 2 - 1
packages/ckeditor5-typing/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