ソースを参照

Merge pull request #54 from alexeckermann/soft-breaks

Feature: Introduced the `ShiftEnter` plugin (support for inserting soft breaks by pressing <kbd>Shift</kbd>+<kbd>Enter</kbd>). This plugin will also be added to the `Essentials` plugin which is available in all official builds, so soft break support will automatically be present in all builds now. Closes #2.

Huge thanks to [Alex Eckermann](https://github.com/alexeckermann) for this contribution!
Piotrek Koszuliński 7 年 前
コミット
bf5a647d40

+ 2 - 2
packages/ckeditor5-enter/README.md

@@ -1,4 +1,4 @@
-CKEditor 5 Enter feature
+CKEditor 5 enter feature
 ========================================
 
 [![Join the chat at https://gitter.im/ckeditor/ckeditor5](https://badges.gitter.im/ckeditor/ckeditor5.svg)](https://gitter.im/ckeditor/ckeditor5?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
@@ -10,7 +10,7 @@ CKEditor 5 Enter feature
 [![Dependency Status](https://david-dm.org/ckeditor/ckeditor5-enter/status.svg)](https://david-dm.org/ckeditor/ckeditor5-enter)
 [![devDependency Status](https://david-dm.org/ckeditor/ckeditor5-enter/dev-status.svg)](https://david-dm.org/ckeditor/ckeditor5-enter?type=dev)
 
-This package implements the <kbd>Enter</kbd> key support for CKEditor 5.
+This package implements the <kbd>Enter</kbd> and <kbd>Shift</kbd>+<kbd>Enter</kbd> (soft break) support for CKEditor 5.
 
 ## Documentation
 

+ 1 - 0
packages/ckeditor5-enter/package.json

@@ -17,6 +17,7 @@
     "@ckeditor/ckeditor5-basic-styles": "^10.0.0",
     "@ckeditor/ckeditor5-editor-classic": "^10.0.0",
     "@ckeditor/ckeditor5-heading": "^10.0.0",
+    "@ckeditor/ckeditor5-paragraph": "^10.0.0",
     "@ckeditor/ckeditor5-typing": "^10.0.0",
     "@ckeditor/ckeditor5-undo": "^10.0.0",
     "eslint": "^4.15.0",

+ 8 - 2
packages/ckeditor5-enter/src/enter.js

@@ -12,7 +12,9 @@ import EnterCommand from './entercommand';
 import EnterObserver from './enterobserver';
 
 /**
- * The Enter feature. Handles the <kbd>Enter</kbd> and <kbd>Shift + Enter</kbd> keys in the editor.
+ * This plugin handles the <kbd>Enter</kbd> key (hard line break) in the editor.
+ *
+ * See also the {@link module:enter/shiftenter~ShiftEnter} plugin.
  *
  * @extends module:core/plugin~Plugin
  */
@@ -33,8 +35,12 @@ export default class Enter extends Plugin {
 
 		editor.commands.add( 'enter', new EnterCommand( editor ) );
 
-		// TODO We may use the keystroke handler for that.
 		this.listenTo( viewDocument, 'enter', ( evt, data ) => {
+			// The soft enter key is handled by the ShiftEnter plugin.
+			if ( data.isSoft ) {
+				return;
+			}
+
 			editor.execute( 'enter' );
 			data.preventDefault();
 			view.scrollToTheSelection();

+ 9 - 5
packages/ckeditor5-enter/src/enterobserver.js

@@ -20,15 +20,17 @@ export default class EnterObserver extends Observer {
 	constructor( view ) {
 		super( view );
 
-		const document = this.document;
+		const doc = this.document;
 
-		document.on( 'keydown', ( evt, data ) => {
+		doc.on( 'keydown', ( evt, data ) => {
 			if ( this.isEnabled && data.keyCode == keyCodes.enter ) {
 				// Save the event object to check later if it was stopped or not.
 				let event;
-				document.once( 'enter', evt => ( event = evt ), { priority: 'highest' } );
+				doc.once( 'enter', evt => ( event = evt ), { priority: 'highest' } );
 
-				document.fire( 'enter', new DomEventData( document, data.domEvent ) );
+				doc.fire( 'enter', new DomEventData( doc, data.domEvent, {
+					isSoft: data.shiftKey
+				} ) );
 
 				// Stop `keydown` event if `enter` event was stopped.
 				// https://github.com/ckeditor/ckeditor5/issues/753
@@ -49,8 +51,10 @@ export default class EnterObserver extends Observer {
  * Event fired when the user presses the <kbd>Enter</kbd> key.
  *
  * Note: This event is fired by the {@link module:enter/enterobserver~EnterObserver observer}
- * (usually registered by the {@link module:enter/enter~Enter Enter feature}).
+ * (usually registered by the {@link module:enter/enter~Enter Enter feature} and
+ * {@link module:enter/shiftenter~ShiftEnter ShiftEnter feature}).
  *
  * @event module:engine/view/document~Document#event:enter
  * @param {module:engine/view/observer/domeventdata~DomEventData} data
+ * @param {Boolean} data.isSoft Whether it's a soft enter (<kbd>Shift</kbd>+<kbd>Enter</kbd>) or hard enter (<kbd>Enter</kbd>).
  */

+ 71 - 0
packages/ckeditor5-enter/src/shiftenter.js

@@ -0,0 +1,71 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module enter/shiftenter
+ */
+
+import ShiftEnterCommand from './shiftentercommand';
+import EnterObserver from './enterobserver';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+import { downcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';
+
+/**
+ * This plugin handles the <kbd>Shift</kbd>+<kbd>Enter</kbd> keystroke (soft line break) in the editor.
+ *
+ * See also the {@link module:enter/enter~Enter} plugin.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ShiftEnter extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'ShiftEnter';
+	}
+
+	init() {
+		const editor = this.editor;
+		const schema = editor.model.schema;
+		const conversion = editor.conversion;
+		const view = editor.editing.view;
+		const viewDocument = view.document;
+
+		// Configure the schema.
+		schema.register( 'softBreak', {
+			allowWhere: '$text'
+		} );
+
+		// Configure converters.
+		conversion.for( 'upcast' )
+			.add( upcastElementToElement( {
+				model: 'softBreak',
+				view: 'br'
+			} ) );
+
+		conversion.for( 'downcast' )
+			.add( downcastElementToElement( {
+				model: 'softBreak',
+				view: ( modelElement, viewWriter ) => viewWriter.createEmptyElement( 'br' )
+			} ) );
+
+		view.addObserver( EnterObserver );
+
+		editor.commands.add( 'shiftEnter', new ShiftEnterCommand( editor ) );
+
+		this.listenTo( viewDocument, 'enter', ( evt, data ) => {
+			// The hard enter key is handled by the Enter plugin.
+			if ( !data.isSoft ) {
+				return;
+			}
+
+			editor.execute( 'shiftEnter' );
+			data.preventDefault();
+			view.scrollToTheSelection();
+		}, { priority: 'low' } );
+	}
+}

+ 132 - 0
packages/ckeditor5-enter/src/shiftentercommand.js

@@ -0,0 +1,132 @@
+/**
+ * @module enter/shiftentercommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+
+/**
+ * ShiftEnter command. It is used by the {@link module:enter/shiftenter~ShiftEnter ShiftEnter feature} to handle
+ * the <kbd>Shift</kbd>+<kbd>Enter</kbd> keystroke.
+ *
+ * @extends module:core/command~Command
+ */
+export default class ShiftEnterCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	execute() {
+		const model = this.editor.model;
+		const doc = model.document;
+
+		model.change( writer => {
+			softBreakAction( model, writer, doc.selection );
+			this.fire( 'afterExecute', { writer } );
+		} );
+	}
+
+	refresh() {
+		const model = this.editor.model;
+		const doc = model.document;
+
+		this.isEnabled = isEnabled( model.schema, doc.selection );
+	}
+}
+
+// Checks whether the shiftEnter command should be enabled in the specified selection.
+//
+// @param {module:engine/model/schema~Schema} schema
+// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection
+function isEnabled( schema, selection ) {
+	// At this moment it is okay to support single range selections only.
+	// But in the future we may need to change that.
+	if ( selection.rangeCount > 1 ) {
+		return false;
+	}
+
+	const anchorPos = selection.anchor;
+
+	// Check whether the break element can be inserted in the current selection anchor.
+	if ( !anchorPos || !schema.checkChild( anchorPos, 'softBreak' ) ) {
+		return false;
+	}
+
+	const range = selection.getFirstRange();
+	const startElement = range.start.parent;
+	const endElement = range.end.parent;
+
+	// Do not modify the content if selection is cross-limit elements.
+	if ( ( isInsideLimitElement( startElement, schema ) || isInsideLimitElement( endElement, schema ) ) && startElement !== endElement ) {
+		return false;
+	}
+
+	return true;
+}
+
+// Creates a break in the way that the <kbd>Shift+Enter</kbd> key is expected to work.
+//
+// @param {module:engine/model~Model} model
+// @param {module:engine/model/writer~Writer} writer
+// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection
+// Selection on which the action should be performed.
+function softBreakAction( model, writer, selection ) {
+	const isSelectionEmpty = selection.isCollapsed;
+	const range = selection.getFirstRange();
+	const startElement = range.start.parent;
+	const endElement = range.end.parent;
+	const isContainedWithinOneElement = ( startElement == endElement );
+
+	if ( isSelectionEmpty ) {
+		insertBreak( writer, range.end );
+	} else {
+		const leaveUnmerged = !( range.start.isAtStart && range.end.isAtEnd );
+		model.deleteContent( selection, { leaveUnmerged } );
+
+		// Selection within one element:
+		//
+		// <h>x[xx]x</h>		-> <h>x^x</h>			-> <h>x<br>^x</h>
+		if ( isContainedWithinOneElement ) {
+			insertBreak( writer, selection.focus );
+		}
+		// Selection over multiple elements.
+		//
+		// <h>x[x</h><p>y]y<p>	-> <h>x^</h><p>y</p>	-> <h>x</h><p>^y</p>
+		//
+		// We chose not to insert a line break in this case because:
+		//
+		// * it's not a very common scenario,
+		// * it actually surprised me when I saw "expected behaviour" in real life.
+		//
+		// It's ok if the user will need to be more specific where they want the <br> to be inserted.
+		else {
+			// Move the selection to the 2nd element (last step of the example above).
+			if ( leaveUnmerged ) {
+				writer.setSelection( endElement, 0 );
+			}
+		}
+	}
+}
+
+function insertBreak( writer, position ) {
+	const breakLineElement = writer.createElement( 'softBreak' );
+
+	writer.insert( breakLineElement, position );
+	writer.setSelection( breakLineElement, 'after' );
+}
+
+// Checks whether specified `element` is a children of limit element.
+//
+// Checking whether the `<p>` element is inside a limit element:
+//   - <$root><p>Text.</p></$root> => false
+//   - <$root><limitElement><p>Text</p></limitElement></$root> => true
+//
+// @param {module:engine/model/element~Element} element
+// @param {module:engine/schema~Schema} schema
+// @returns {Boolean}
+function isInsideLimitElement( element, schema ) {
+	// `$root` is a limit element but in this case is an invalid element.
+	if ( element.is( 'rootElement' ) ) {
+		return false;
+	}
+
+	return schema.isLimit( element ) || isInsideLimitElement( element.parent, schema );
+}

+ 17 - 1
packages/ckeditor5-enter/tests/enter.js

@@ -6,6 +6,7 @@
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import Enter from '../src/enter';
 import EnterCommand from '../src/entercommand';
+import EnterObserver from '../src/enterobserver';
 import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
 
 describe( 'Enter feature', () => {
@@ -26,11 +27,17 @@ describe( 'Enter feature', () => {
 		expect( editor.commands.get( 'enter' ) ).to.be.instanceof( EnterCommand );
 	} );
 
+	it( 'registers the EnterObserver', () => {
+		const observer = editor.editing.view.getObserver( EnterObserver );
+
+		expect( observer ).to.be.an.instanceOf( EnterObserver );
+	} );
+
 	it( 'listens to the editing view enter event', () => {
 		const spy = editor.execute = sinon.spy();
 		const domEvt = getDomEvent();
 
-		viewDocument.fire( 'enter', new DomEventData( viewDocument, domEvt ) );
+		viewDocument.fire( 'enter', new DomEventData( viewDocument, domEvt, { isSoft: false } ) );
 
 		expect( spy.calledOnce ).to.be.true;
 		expect( spy.calledWithExactly( 'enter' ) ).to.be.true;
@@ -49,6 +56,15 @@ describe( 'Enter feature', () => {
 		sinon.assert.callOrder( executeSpy, scrollSpy );
 	} );
 
+	it( 'does not execute the command if soft enter should be used', () => {
+		const domEvt = getDomEvent();
+		const commandExecuteSpy = sinon.stub( editor.commands.get( 'enter' ), 'execute' );
+
+		viewDocument.fire( 'enter', new DomEventData( viewDocument, domEvt, { isSoft: true } ) );
+
+		sinon.assert.notCalled( commandExecuteSpy );
+	} );
+
 	function getDomEvent() {
 		return {
 			preventDefault: sinon.spy()

+ 17 - 1
packages/ckeditor5-enter/tests/enterobserver.js

@@ -35,10 +35,26 @@ describe( 'EnterObserver', () => {
 			viewDocument.on( 'enter', spy );
 
 			viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
-				keyCode: getCode( 'enter' )
+				keyCode: getCode( 'enter' ),
+				shiftKey: false
+			} ) );
+
+			expect( spy.calledOnce ).to.be.true;
+			expect( spy.firstCall.args[ 1 ].isSoft ).to.be.false;
+		} );
+
+		it( 'detects whether shift was pressed along with the "enter" key', () => {
+			const spy = sinon.spy();
+
+			viewDocument.on( 'enter', spy );
+
+			viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+				keyCode: getCode( 'enter' ),
+				shiftKey: true
 			} ) );
 
 			expect( spy.calledOnce ).to.be.true;
+			expect( spy.firstCall.args[ 1 ].isSoft ).to.be.true;
 		} );
 
 		it( 'is not fired on keydown when keyCode does not match enter', () => {

+ 6 - 0
packages/ckeditor5-enter/tests/manual/enter.html

@@ -3,4 +3,10 @@
 	<h3>Heading 2</h3>
 	<h4>Heading 3</h4>
 	<p>Paragraph</p>
+	<p>
+		Lorem ipsum dolor sit amet, consectetur adipiscing elit.<br>
+		Nulla finibus consequat placerat.<br>
+		Vestibulum id tellus et mauris sagittis tincidunt quis id mauris.<br>
+		Curabitur consectetur lectus sit amet tellus mattis, non lobortis leo interdum.
+	</p>
 </div>

+ 2 - 1
packages/ckeditor5-enter/tests/manual/enter.js

@@ -7,6 +7,7 @@
 
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 import Enter from '../../src/enter';
+import ShiftEnter from '../../src/shiftenter';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import Heading from '@ckeditor/ckeditor5-heading/src/heading';
 import Undo from '@ckeditor/ckeditor5-undo/src/undo';
@@ -15,7 +16,7 @@ import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
-		plugins: [ Enter, Typing, Heading, Undo, Bold, Italic ],
+		plugins: [ Enter, ShiftEnter, Typing, Heading, Undo, Bold, Italic ],
 		toolbar: [ 'heading', '|', 'bold', 'italic', 'undo', 'redo' ]
 	} )
 	.then( editor => {

+ 1 - 1
packages/ckeditor5-enter/tests/manual/enter.md

@@ -7,7 +7,7 @@ Test the <kbd>Enter</kbd> key support.
 * Expected behavior:
 	* At the end of a heading should create a new paragraph.
 	* In the middle of a heading should split it.
-	* <kbd>Shift+Enter</kbd> should have <kbd>Enter</kbd> behavior.
+	* <kbd>Shift+Enter</kbd> should move the text to new line.
 	* The selection should always be moved to the newly created block.
 	* Select all + <kbd>Enter</kbd> should leave an empty paragraph.
 * Check:

+ 54 - 0
packages/ckeditor5-enter/tests/shiftenter-integration.js

@@ -0,0 +1,54 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import ShiftEnter from '../src/shiftenter';
+
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+
+describe( 'ShiftEnter integration', () => {
+	let editor, model, div;
+
+	const options = { withoutSelection: true };
+
+	beforeEach( () => {
+		div = document.createElement( 'div' );
+		div.innerHTML = '<p>First line.<br>Second line.</p>';
+
+		document.body.appendChild( div );
+
+		return ClassicEditor.create( div, { plugins: [ Paragraph, ShiftEnter ] } )
+			.then( newEditor => {
+				editor = newEditor;
+
+				model = editor.model;
+			} );
+	} );
+
+	afterEach( () => {
+		div.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'loads correct data', () => {
+		expect( getModelData( model, options ) ).to.equal( '<paragraph>First line.<softBreak></softBreak>Second line.</paragraph>' );
+		expect( getViewData( editor.editing.view, options ) ).to.equal( '<p>First line.<br></br>Second line.</p>' );
+	} );
+
+	it( 'BLOCK_FILLER should be inserted after <br> in the paragraph', () => {
+		setModelData( model, '<paragraph>[]</paragraph>' );
+
+		editor.commands.get( 'shiftEnter' ).execute();
+
+		expect( editor.getData() ).to.equal( '<p><br>&nbsp;</p>' );
+		expect( editor.ui.view.editable.element.innerHTML ).to.equal( '<p><br><br data-cke-filler="true"></p>' );
+	} );
+} );

+ 73 - 0
packages/ckeditor5-enter/tests/shiftenter.js

@@ -0,0 +1,73 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import ShiftEnter from '../src/shiftenter';
+import ShiftEnterCommand from '../src/shiftentercommand';
+import EnterObserver from '../src/enterobserver';
+import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
+
+describe( 'ShiftEnter feature', () => {
+	let editor, viewDocument;
+
+	beforeEach( () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ ShiftEnter ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				viewDocument = editor.editing.view.document;
+			} );
+	} );
+
+	it( 'creates the commands', () => {
+		expect( editor.commands.get( 'shiftEnter' ) ).to.be.instanceof( ShiftEnterCommand );
+	} );
+
+	it( 'registers the EnterObserver', () => {
+		const observer = editor.editing.view.getObserver( EnterObserver );
+
+		expect( observer ).to.be.an.instanceOf( EnterObserver );
+	} );
+
+	it( 'listens to the editing view enter event', () => {
+		const spy = editor.execute = sinon.spy();
+		const domEvt = getDomEvent();
+
+		viewDocument.fire( 'enter', new DomEventData( viewDocument, domEvt, { isSoft: true } ) );
+
+		expect( spy.calledOnce ).to.be.true;
+		expect( spy.calledWithExactly( 'shiftEnter' ) ).to.be.true;
+
+		expect( domEvt.preventDefault.calledOnce ).to.be.true;
+	} );
+
+	it( 'scrolls the editing document to the selection after executing the command', () => {
+		const domEvt = getDomEvent();
+		const executeSpy = editor.execute = sinon.spy();
+		const scrollSpy = sinon.stub( editor.editing.view, 'scrollToTheSelection' );
+
+		viewDocument.fire( 'enter', new DomEventData( viewDocument, domEvt, { isSoft: true } ) );
+
+		sinon.assert.calledOnce( scrollSpy );
+		sinon.assert.callOrder( executeSpy, scrollSpy );
+	} );
+
+	it( 'does not execute the command if hard enter should be used', () => {
+		const domEvt = getDomEvent();
+		const commandExecuteSpy = sinon.stub( editor.commands.get( 'shiftEnter' ), 'execute' );
+
+		viewDocument.fire( 'enter', new DomEventData( viewDocument, domEvt, { isSoft: false } ) );
+
+		sinon.assert.notCalled( commandExecuteSpy );
+	} );
+
+	function getDomEvent() {
+		return {
+			preventDefault: sinon.spy()
+		};
+	}
+} );

+ 317 - 0
packages/ckeditor5-enter/tests/shiftentercommand.js

@@ -0,0 +1,317 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import InsertDelta from '@ckeditor/ckeditor5-engine/src/model/delta/insertdelta';
+import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import ShiftEnter from '../src/shiftenter';
+
+describe( 'ShiftEnterCommand', () => {
+	let editor, model, doc, schema, command;
+
+	beforeEach( () => {
+		return ModelTestEditor.create( { plugins: [ ShiftEnter ] } )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				doc = model.document;
+
+				command = editor.commands.get( 'shiftEnter' );
+
+				schema = model.schema;
+
+				// Note: We could use real names like 'paragraph', but that would make test patterns too long.
+				// Plus, this is actually a good test that the algorithm can be used for any model.
+				schema.register( 'img', { allowWhere: '$block' } );
+				schema.register( 'p', {
+					inheritAllFrom: '$block',
+					allowIn: 'blockLimit'
+				} );
+				schema.register( 'h', { inheritAllFrom: '$block' } );
+				schema.register( 'inlineLimit', {
+					allowIn: 'p',
+					isLimit: true
+				} );
+				schema.register( 'blockLimit', {
+					allowIn: '$root',
+					isLimit: true
+				} );
+				schema.extend( '$text', {
+					allowIn: [ 'inlineLimit', '$root' ]
+				} );
+			} );
+	} );
+
+	describe( 'ShiftEnterCommand', () => {
+		it( 'soft breaks a block using parent batch', () => {
+			setData( model, '<p>foo[]</p>' );
+
+			model.change( writer => {
+				expect( writer.batch.deltas ).to.length( 0 );
+				editor.execute( 'shiftEnter' );
+				expect( writer.batch.deltas ).to.length.above( 0 );
+			} );
+		} );
+
+		it( 'creates InsertDelta if soft enter is at the beginning of block', () => {
+			setData( model, '<p>[]foo</p>' );
+
+			editor.execute( 'shiftEnter' );
+
+			const deltas = Array.from( doc.history.getDeltas() );
+
+			expect( deltas[ deltas.length - 1 ] ).to.be.instanceof( InsertDelta );
+		} );
+
+		it( 'creates InsertDelta if soft enter is at the end of block', () => {
+			setData( model, '<p>foo[]</p>' );
+
+			editor.execute( 'shiftEnter' );
+
+			const deltas = Array.from( doc.history.getDeltas() );
+
+			expect( deltas[ deltas.length - 1 ] ).to.be.instanceof( InsertDelta );
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		describe( 'collapsed selection', () => {
+			test(
+				'inserts in the root',
+				'foo[]bar',
+				'foo<softBreak></softBreak>[]bar'
+			);
+
+			test(
+				'inserts inside block',
+				'<p>x</p><p>foo[]bar</p><p>y</p>',
+				'<p>x</p><p>foo<softBreak></softBreak>[]bar</p><p>y</p>'
+			);
+
+			test(
+				'inserts at the end of block',
+				'<p>x</p><p>foo[]</p><p>y</p>',
+				'<p>x</p><p>foo<softBreak></softBreak>[]</p><p>y</p>'
+			);
+
+			test(
+				'inserts at the beginning of block',
+				'<p>x</p><p>[]foo</p><p>y</p>',
+				'<p>x</p><p><softBreak></softBreak>[]foo</p><p>y</p>'
+			);
+		} );
+
+		describe( 'non-collapsed selection', () => {
+			test(
+				'deletes the content and inserts the break when directly in the root',
+				'fo[ob]ar',
+				'fo<softBreak></softBreak>[]ar'
+			);
+
+			test(
+				'deletes text and adds break',
+				'<p>ab[cd]ef</p><p>ghi</p>',
+				'<p>ab<softBreak></softBreak>[]ef</p><p>ghi</p>'
+			);
+
+			test(
+				'places selection in the 2nd element',
+				'<h>ab[c</h><p>d]ef</p><p>ghi</p>',
+				'<h>ab</h><p>[]ef</p><p>ghi</p>'
+			);
+
+			test(
+				'does nothing for selection that contains more than one range',
+				'<p>[abc]</p><p>[def]</p>',
+				'<p>[abc]</p><p>[def]</p>'
+			);
+
+			test(
+				'inserts break in empty element after it was fully selected',
+				'<p>x</p><p>[abcdef]</p><p>y</p>',
+				'<p>x</p><p><softBreak></softBreak>[]</p><p>y</p>'
+			);
+
+			// See: comment in softBreakAction().
+			test(
+				'leaves one empty element after two were fully selected',
+				'<p>[abc</p><p>def]</p>',
+				'<p>[]</p>'
+			);
+
+			test(
+				'should insert the break in inline limit element - collapsed',
+				'<p><inlineLimit>foo[]bar</inlineLimit></p>',
+				'<p><inlineLimit>foo<softBreak></softBreak>[]bar</inlineLimit></p>'
+			);
+
+			test(
+				'should insert the break in inline limit elements',
+				'<p><inlineLimit>foo[bar]baz</inlineLimit></p>',
+				'<p><inlineLimit>foo<softBreak></softBreak>[]baz</inlineLimit></p>'
+			);
+
+			test(
+				'should insert the break at beginning of the inline limit elements',
+				'<p><inlineLimit>foo[bar]baz</inlineLimit></p>',
+				'<p><inlineLimit>foo<softBreak></softBreak>[]baz</inlineLimit></p>'
+			);
+
+			test(
+				'should insert the break at ending of the inline limit elements',
+				'<p><inlineLimit>foobaz[]</inlineLimit></p>',
+				'<p><inlineLimit>foobaz<softBreak></softBreak>[]</inlineLimit></p>'
+			);
+
+			test(
+				'should not break inline limit elements - selection partially inside',
+				'<p><inlineLimit>ba[r</inlineLimit></p><p>f]oo</p>',
+				'<p><inlineLimit>ba[r</inlineLimit></p><p>f]oo</p>'
+			);
+
+			test(
+				'should break paragraph in blockLimit',
+				'<blockLimit><p>foo[]bar</p></blockLimit>',
+				'<blockLimit><p>foo<softBreak></softBreak>[]bar</p></blockLimit>'
+			);
+
+			test(
+				'does nothing when break element cannot be inserted in specified context',
+				'<img>[]</img>',
+				'<img>[]</img>'
+			);
+
+			it( 'leaves one empty element after two were fully selected (backward)', () => {
+				setData( model, '<p>[abc</p><p>def]</p>' );
+				// @TODO: Add option for setting selection direction to model utils.
+				doc.selection._lastRangeBackward = true;
+
+				command.execute();
+
+				expect( getData( model ) ).to.equal( '<p>[]</p>' );
+			} );
+
+			it( 'uses DataController.deleteContent', () => {
+				const spy = sinon.spy();
+
+				editor.model.on( 'deleteContent', spy );
+
+				setData( model, '<p>[x]</p>' );
+
+				command.execute();
+
+				expect( spy.calledOnce ).to.be.true;
+			} );
+		} );
+
+		function test( title, input, output ) {
+			it( title, () => {
+				setData( model, input );
+
+				command.execute();
+
+				expect( getData( model ) ).to.equal( output );
+			} );
+		}
+	} );
+
+	describe( '#isEnabled', () => {
+		it( 'should be disabled if <softBreak> cannot be inserted into element', () => {
+			setData( model, '<img>[]</img>' );
+
+			expect( command.isEnabled ).to.equal( false );
+		} );
+
+		it( 'should be enabled for collapsed selection in $root', () => {
+			setData( model, 'Foo.[]' );
+
+			expect( command.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be enabled for collapsed selection in paragraph', () => {
+			setData( model, '<p>Foo.[]</p>' );
+
+			expect( command.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be enabled for collapsed selection in heading', () => {
+			setData( model, '<h>Foo.[]</h>' );
+
+			expect( command.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be enabled for collapsed selection in inline limit element', () => {
+			setData( model, '<p><inlineLimit>Foo.[]</inlineLimit></p>' );
+
+			expect( command.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be enabled for non-collapsed selection in inline limit element', () => {
+			setData( model, '<p><inlineLimit>[Foo.]</inlineLimit></p>' );
+
+			expect( command.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be enabled for collapsed selection in paragraph which is wrapped in a block limit element', () => {
+			setData( model, '<blockLimit><p>Foo.[]</p></blockLimit>' );
+
+			expect( command.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be enabled for non-collapsed selection in paragraph which is wrapped in a block limit element', () => {
+			setData( model, '<blockLimit><p>F[oo.]</p></blockLimit>' );
+
+			expect( command.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be enabled for non-collapsed selection in paragraphs', () => {
+			setData( model, '<p>[Foo.</p><p>Bar.]</p>' );
+
+			expect( command.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be enabled for non-collapsed selection in headings', () => {
+			setData( model, '<h>[Foo.</h><h>Bar.]</h>' );
+
+			expect( command.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be disabled for non-collapsed selection which starts in an inline limit element', () => {
+			setData( model, '<p><inlineLimit>F[oo.</inlineLimit>B]ar.</p>' );
+
+			expect( command.isEnabled ).to.equal( false );
+		} );
+
+		it( 'should be disabled for non-collapsed selection which end in an inline limit element', () => {
+			setData( model, '<p>F[oo<inlineLimit>Bar].</inlineLimit></p>' );
+
+			expect( command.isEnabled ).to.equal( false );
+		} );
+
+		it( 'should be disabled for non-collapsed selection which starts in element inside a block limit element', () => {
+			setData( model, '<blockLimit><p>F[oo.</p></blockLimit><p>B]ar.</p>' );
+
+			expect( command.isEnabled ).to.equal( false );
+		} );
+
+		it( 'should be disabled for non-collapsed selection which ends in element inside a block limit element', () => {
+			setData( model, '<p>Fo[o.</p><blockLimit><p>Bar].</p></blockLimit>' );
+
+			expect( command.isEnabled ).to.equal( false );
+		} );
+
+		it( 'should be disabled for multi-ranges selection (1)', () => {
+			setData( model, '<p>[x]</p><p>[foo]</p>' );
+
+			expect( command.isEnabled ).to.equal( false );
+		} );
+
+		it( 'should be disabled for multi-ranges selection (2)', () => {
+			setData( model, '<p>[]x</p><p>[]foo</p>' );
+
+			expect( command.isEnabled ).to.equal( false );
+		} );
+	} );
+} );