소스 검색

Improved the typing – when the user held the Backspace or pressed the Backspace inside the empty heading element.

Kamil Piechaczek 8 년 전
부모
커밋
a2403dbcea

+ 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();
 		} );
 	}

+ 88 - 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,8 @@ 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] See the {@link module:engine/view/document~Document#event:delete} event data.
 	 */
 	execute( options = {} ) {
 		const doc = this.editor.document;
@@ -73,6 +76,12 @@ export default class DeleteCommand extends Command {
 
 			// If selection is still collapsed, then there's nothing to delete.
 			if ( selection.isCollapsed ) {
+				const sequence = options.sequence || 1;
+
+				if ( this._shouldEntireContentBeReplacedWithParagraph( { sequence } ) ) {
+					this._replaceEntireContentWithParagraph();
+				}
+
 				return;
 			}
 
@@ -92,4 +101,81 @@ export default class DeleteCommand extends Command {
 			this._buffer.unlock();
 		} );
 	}
+
+	/**
+	 * If the user keeps <kbd>Backspace</kbd> or <kbd>Delete</kbd> key, we do nothing because the user can clear
+	 * the whole element without removing them.
+	 *
+	 * But, if the user pressed and released the key, we want to replace the entire content with a paragraph if:
+	 *   - the entire content is selected,
+	 *   - the paragraph is allowed in the common ancestor,
+	 *   - other paragraph does not occur in the editor.
+	 *
+	 * @private
+	 * @param {Object} options
+	 * @param {Number} options.sequence A number that describes which sequence of the same event is fired.
+	 * @returns {Boolean}
+	 */
+	_shouldEntireContentBeReplacedWithParagraph( options ) {
+		// Does nothing if user pressed and held the "Backspace" or "Delete" key.
+		if ( options.sequence > 1 ) {
+			return false;
+		}
+
+		const document = this.editor.document;
+		const selection = document.selection;
+		const limitElement = getLimitElement( document.schema, selection );
+		const limitStartPosition = Position.createAt( limitElement );
+		const limitEndPosition = Position.createAt( limitElement, 'end' );
+
+		if (
+			!limitStartPosition.isTouching( selection.getFirstPosition() ) ||
+			!limitEndPosition.isTouching( selection.getLastPosition() )
+		) {
+			return false;
+		}
+
+		if ( !document.schema.check( { name: 'paragraph', inside: limitElement.name } ) ) {
+			return false;
+		}
+
+		// Does nothing if editor contains an empty paragraph.
+		if ( selection.getFirstRange().getCommonAncestor().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 = getLimitElement( document.schema, selection );
+		const paragraph = new Element( 'paragraph' );
+
+		this._buffer.batch.remove( Range.createIn( limitElement ) );
+		this._buffer.batch.insert( Position.createAt( limitElement ), paragraph );
+
+		selection.collapse( paragraph );
+	}
+}
+
+// Returns the lowest limit element defined in `Schema.limits` for passed selection.
+function getLimitElement( schema, selection ) {
+	let element = selection.getFirstRange().getCommonAncestor();
+
+	while ( !schema.limits.has( element.name ) ) {
+		if ( element.parent ) {
+			element = element.parent;
+		} else {
+			break;
+		}
+	}
+
+	return element;
 }

+ 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 that describes which sequence of the same event is fired.
+ * It helps detect the key was pressed and held.
  */

+ 6 - 4
packages/ckeditor5-typing/tests/delete.js

@@ -34,21 +34,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;
 	} );
 
 	function getDomEvent() {

+ 94 - 12
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,7 +39,7 @@ describe( 'DeleteCommand', () => {
 
 	describe( 'execute()', () => {
 		it( 'uses enqueueChanges', () => {
-			setData( doc, '<p>foo[]bar</p>' );
+			setData( doc, '<paragraph>foo[]bar</paragraph>' );
 
 			const spy = testUtils.sinon.spy( doc, 'enqueueChanges' );
 
@@ -48,7 +49,7 @@ describe( 'DeleteCommand', () => {
 		} );
 
 		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' );
@@ -61,38 +62,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, { selection: true } ) ).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, { selection: true } ) ).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, { selection: true } ) ).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, { selection: true } ) ).to.equal( '<paragraph>[]foo</paragraph>' );
 			expect( spy.callCount ).to.equal( 0 );
 		} );
 
@@ -100,7 +101,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';
 
@@ -112,5 +113,86 @@ describe( 'DeleteCommand', () => {
 			expect( modifyOpts ).to.have.property( 'direction', 'forward' );
 			expect( modifyOpts ).to.have.property( 'unit', 'word' );
 		} );
+
+		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, { selection: true } ) ).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, { selection: true } ) ).to.equal(
+				'<heading1>Foo</heading1>' +
+				'<section>' +
+					'<paragraph>[]</paragraph>' +
+				'</section>' +
+				'<paragraph>Bar.</paragraph>'
+			);
+		} );
+
+		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, { selection: true } ) ).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, { selection: true } ) ).to.equal( '<heading1>[]</heading1>' );
+		} );
+
+		it( 'does not replace an element if a paragraph is a common ancestor', () => {
+			setData( doc, '<paragraph>[]</paragraph>' );
+
+			const element = doc.selection.getFirstRange().getCommonAncestor();
+
+			editor.execute( 'delete' );
+
+			expect( element ).is.equal( doc.selection.getFirstRange().getCommonAncestor() );
+		} );
+
+		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, { selection: true } ) ).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() {