Explorar o código

Merge branch 'master' into t/1021

Piotrek Koszuliński %!s(int64=8) %!d(string=hai) anos
pai
achega
06ca4c9b50
Modificáronse 22 ficheiros con 441 adicións e 78 borrados
  1. 70 8
      packages/ckeditor5-engine/src/controller/deletecontent.js
  2. 12 16
      packages/ckeditor5-engine/src/controller/editingcontroller.js
  3. 2 0
      packages/ckeditor5-engine/src/model/schema.js
  4. 11 1
      packages/ckeditor5-engine/src/view/document.js
  5. 5 1
      packages/ckeditor5-engine/src/view/editableelement.js
  6. 2 0
      packages/ckeditor5-engine/src/view/element.js
  7. 18 3
      packages/ckeditor5-engine/src/view/observer/keyobserver.js
  8. 4 1
      packages/ckeditor5-engine/src/view/observer/selectionobserver.js
  9. 47 29
      packages/ckeditor5-engine/src/view/renderer.js
  10. 94 2
      packages/ckeditor5-engine/tests/controller/deletecontent.js
  11. 22 5
      packages/ckeditor5-engine/tests/controller/editingcontroller.js
  12. 4 0
      packages/ckeditor5-engine/tests/model/schema/schema.js
  13. 1 0
      packages/ckeditor5-engine/tests/view/_utils/createdocumentmock.js
  14. 4 3
      packages/ckeditor5-engine/tests/view/document/document.js
  15. 13 0
      packages/ckeditor5-engine/tests/view/editableelement.js
  16. 1 0
      packages/ckeditor5-engine/tests/view/manual/keyobserver.js
  17. 2 0
      packages/ckeditor5-engine/tests/view/manual/keyobserver.md
  18. 50 4
      packages/ckeditor5-engine/tests/view/observer/keyobserver.js
  19. 20 0
      packages/ckeditor5-engine/tests/view/observer/selectionobserver.js
  20. 8 0
      packages/ckeditor5-engine/tests/view/position.js
  21. 39 0
      packages/ckeditor5-engine/tests/view/renderer.js
  22. 12 5
      packages/ckeditor5-engine/tests/view/utils-tests/createdocumentmock.js

+ 70 - 8
packages/ckeditor5-engine/src/controller/deletecontent.js

@@ -32,17 +32,24 @@ export default function deleteContent( selection, batch, options = {} ) {
 		return;
 	}
 
-	const selRange = selection.getFirstRange();
+	// 1. Replace the entire content with paragraph.
+	// See: https://github.com/ckeditor/ckeditor5-engine/issues/1012#issuecomment-315017594.
+	if ( shouldEntireContentBeReplacedWithParagraph( batch.document.schema, selection ) ) {
+		replaceEntireContentWithParagraph( batch, selection );
+
+		return;
+	}
 
+	const selRange = selection.getFirstRange();
 	const startPos = selRange.start;
 	const endPos = LivePosition.createFromPosition( selRange.end );
 
-	// 1. Remove the content if there is any.
+	// 2. Remove the content if there is any.
 	if ( !selRange.start.isTouching( selRange.end ) ) {
 		batch.remove( selRange );
 	}
 
-	// 2. Merge elements in the right branch to the elements in the left branch.
+	// 3. Merge elements in the right branch to the elements in the left branch.
 	// The only reasonable (in terms of data and selection correctness) case in which we need to do that is:
 	//
 	// <heading type=1>Fo[</heading><paragraph>]ar</paragraph> => <heading type=1>Fo^ar</heading>
@@ -56,13 +63,10 @@ export default function deleteContent( selection, batch, options = {} ) {
 
 	selection.collapse( startPos );
 
-	// 3. Autoparagraphing.
+	// 4. Autoparagraphing.
 	// Check if a text is allowed in the new container. If not, try to create a new paragraph (if it's allowed here).
 	if ( shouldAutoparagraph( batch.document, startPos ) ) {
-		const paragraph = new Element( 'paragraph' );
-		batch.insert( startPos, paragraph );
-
-		selection.collapse( paragraph );
+		insertParagraph( batch, startPos, selection );
 	}
 
 	endPos.detach();
@@ -163,3 +167,61 @@ function checkCanBeMerged( leftPos, rightPos ) {
 
 	return true;
 }
+
+// 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;
+}
+
+function insertParagraph( batch, position, selection ) {
+	const paragraph = new Element( 'paragraph' );
+	batch.insert( position, paragraph );
+
+	selection.collapse( paragraph );
+}
+
+function replaceEntireContentWithParagraph( batch, selection ) {
+	const limitElement = getLimitElement( batch.document.schema, selection );
+
+	batch.remove( Range.createIn( limitElement ) );
+	insertParagraph( batch, Position.createAt( limitElement ), selection );
+}
+
+// We want to replace the entire content with a paragraph when:
+// * the entire content is selected,
+// * selection contains at least two elements,
+// * whether the paragraph is allowed in schema in the common ancestor.
+function shouldEntireContentBeReplacedWithParagraph( schema, selection ) {
+	const limitElement = getLimitElement( schema, selection );
+	const limitStartPosition = Position.createAt( limitElement );
+	const limitEndPosition = Position.createAt( limitElement, 'end' );
+
+	if (
+		!limitStartPosition.isTouching( selection.getFirstPosition() ) ||
+		!limitEndPosition.isTouching( selection.getLastPosition() )
+	) {
+		return false;
+	}
+
+	const range = selection.getFirstRange();
+
+	if ( range.start.parent == range.end.parent ) {
+		return false;
+	}
+
+	if ( !schema.check( { name: 'paragraph', inside: limitElement.name } ) ) {
+		return false;
+	}
+
+	return true;
+}

+ 12 - 16
packages/ckeditor5-engine/src/controller/editingcontroller.js

@@ -22,12 +22,15 @@ import {
 	clearFakeSelection
 } from '../conversion/model-selection-to-view-converters';
 
-import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
+import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
+import mix from '@ckeditor/ckeditor5-utils/src/mix';
 
 /**
  * Controller for the editing pipeline. The editing pipeline controls {@link ~EditingController#model model} rendering,
  * including selection handling. It also creates {@link ~EditingController#view view document} which build a
  * browser-independent virtualization over the DOM elements. Editing controller also attach default converters.
+ *
+ * @mixes module:utils/observablemixin~ObservableMixin
  */
 export default class EditingController {
 	/**
@@ -80,22 +83,13 @@ export default class EditingController {
 			viewSelection: this.view.selection
 		} );
 
-		/**
-		 * Property keeping all listenters attached by controller on other objects, so it can
-		 * stop listening on {@link #destroy}.
-		 *
-		 * @private
-		 * @member {utils.EmitterMixin} #_listener
-		 */
-		this._listener = Object.create( EmitterMixin );
-
 		// Convert changes in model to view.
-		this._listener.listenTo( this.model, 'change', ( evt, type, changes ) => {
+		this.listenTo( this.model, 'change', ( evt, type, changes ) => {
 			this.modelToView.convertChange( type, changes );
 		}, { priority: 'low' } );
 
 		// Convert model selection to view.
-		this._listener.listenTo( this.model, 'changesDone', () => {
+		this.listenTo( this.model, 'changesDone', () => {
 			const selection = this.model.selection;
 
 			this.modelToView.convertSelection( selection );
@@ -103,16 +97,16 @@ export default class EditingController {
 		}, { priority: 'low' } );
 
 		// Convert model markers changes.
-		this._listener.listenTo( this.model.markers, 'add', ( evt, marker ) => {
+		this.listenTo( this.model.markers, 'add', ( evt, marker ) => {
 			this.modelToView.convertMarker( 'addMarker', marker.name, marker.getRange() );
 		} );
 
-		this._listener.listenTo( this.model.markers, 'remove', ( evt, marker ) => {
+		this.listenTo( this.model.markers, 'remove', ( evt, marker ) => {
 			this.modelToView.convertMarker( 'removeMarker', marker.name, marker.getRange() );
 		} );
 
 		// Convert view selection to model.
-		this._listener.listenTo( this.view, 'selectionChange', convertSelectionChange( this.model, this.mapper ) );
+		this.listenTo( this.view, 'selectionChange', convertSelectionChange( this.model, this.mapper ) );
 
 		// Attach default content converters.
 		this.modelToView.on( 'insert:$text', insertText(), { priority: 'lowest' } );
@@ -158,6 +152,8 @@ export default class EditingController {
 	 */
 	destroy() {
 		this.view.destroy();
-		this._listener.stopListening();
+		this.stopListening();
 	}
 }
+
+mix( EditingController, ObservableMixin );

+ 2 - 0
packages/ckeditor5-engine/src/model/schema.js

@@ -83,6 +83,8 @@ export default class Schema {
 		this.allow( { name: '$block', inside: '$root' } );
 		this.allow( { name: '$inline', inside: '$block' } );
 
+		this.limits.add( '$root' );
+
 		// TMP!
 		// Create an "all allowed" context in the schema for processing the pasted content.
 		// Read: https://github.com/ckeditor/ckeditor5-engine/issues/638#issuecomment-255086588

+ 11 - 1
packages/ckeditor5-engine/src/view/document.js

@@ -79,6 +79,16 @@ export default class Document {
 		 */
 		this.roots = new Map();
 
+		/**
+		 * Defines whether document is in read-only mode.
+		 *
+		 * When document is read-ony then all roots are read-only as well and caret placed inside this root is hidden.
+		 *
+		 * @observable
+		 * @member {Boolean} #isReadOnly
+		 */
+		this.set( 'isReadOnly', false );
+
 		/**
 		 * True if document is focused.
 		 *
@@ -98,7 +108,7 @@ export default class Document {
 		 * @member {module:engine/view/renderer~Renderer} module:engine/view/document~Document#renderer
 		 */
 		this.renderer = new Renderer( this.domConverter, this.selection );
-		this.renderer.bind( 'isFocused' ).to( this, 'isFocused' );
+		this.renderer.bind( 'isFocused' ).to( this );
 
 		/**
 		 * Map of registered {@link module:engine/view/observer/observer~Observer observers}.

+ 5 - 1
packages/ckeditor5-engine/src/view/editableelement.js

@@ -18,8 +18,10 @@ const documentSymbol = Symbol( 'document' );
  * Editable element which can be a {@link module:engine/view/rooteditableelement~RootEditableElement root}
  * or nested editable area in the editor.
  *
+ * Editable is automatically read-only when its {module:engine/view/document~Document Document} is read-only.
+ *
  * @extends module:engine/view/containerelement~ContainerElement
- * @mixes module:utils/observablemixin~ObservaleMixin
+ * @mixes module:utils/observablemixin~ObservableMixin
  */
 export default class EditableElement extends ContainerElement {
 	/**
@@ -74,6 +76,8 @@ export default class EditableElement extends ContainerElement {
 
 		this.setCustomProperty( documentSymbol, document );
 
+		this.bind( 'isReadOnly' ).to( document );
+
 		this.bind( 'isFocused' ).to(
 			document,
 			'isFocused',

+ 2 - 0
packages/ckeditor5-engine/src/view/element.js

@@ -181,6 +181,8 @@ export default class Element extends Node {
 		cloned._customProperties = new Map( this._customProperties );
 
 		// Clone filler offset method.
+		// We can't define this method in a prototype because it's behavior which
+		// is changed by e.g. toWidget() function from ckeditor5-widget. Perhaps this should be one of custom props.
 		cloned.getFillerOffset = this.getFillerOffset;
 
 		return cloned;

+ 18 - 3
packages/ckeditor5-engine/src/view/observer/keyobserver.js

@@ -21,11 +21,11 @@ export default class KeyObserver extends DomEventObserver {
 	constructor( document ) {
 		super( document );
 
-		this.domEventType = 'keydown';
+		this.domEventType = [ 'keydown', 'keyup' ];
 	}
 
 	onDomEvent( domEvt ) {
-		this.fire( 'keydown', domEvt, {
+		this.fire( domEvt.type, domEvt, {
 			keyCode: domEvt.keyCode,
 
 			altKey: domEvt.altKey,
@@ -54,7 +54,22 @@ export default class KeyObserver extends DomEventObserver {
  */
 
 /**
- * The value of the {@link module:engine/view/document~Document#event:keydown} event.
+ * Fired when a key has been released.
+ *
+ * Introduced by {@link module:engine/view/observer/keyobserver~KeyObserver}.
+ *
+ * Note that because {@link module:engine/view/observer/keyobserver~KeyObserver} is attached by the
+ * {@link module:engine/view/document~Document}
+ * this event is available by default.
+ *
+ * @see module:engine/view/observer/keyobserver~KeyObserver
+ * @event module:engine/view/document~Document#event:keyup
+ * @param {module:engine/view/observer/keyobserver~KeyEventData} keyEventData
+ */
+
+/**
+ * The value of both events - {@link module:engine/view/document~Document#event:keydown} and
+ * {@link module:engine/view/document~Document#event:keyup}.
  *
  * @class module:engine/view/observer/keyobserver~KeyEventData
  * @extends module:engine/view/observer/domeventdata~DomEventData

+ 4 - 1
packages/ckeditor5-engine/src/view/observer/selectionobserver.js

@@ -133,7 +133,10 @@ export default class SelectionObserver extends Observer {
 	 * @param {Document} domDocument DOM document.
 	 */
 	_handleSelectionChange( domDocument ) {
-		if ( !this.isEnabled || !this.document.isFocused ) {
+		// Selection is handled when document is not focused but is read-only. This is because in read-only
+		// mode contenteditable is set as false and editor won't receive focus but we still need to know
+		// selection position.
+		if ( !this.isEnabled || ( !this.document.isFocused && !this.document.isReadOnly ) ) {
 			return;
 		}
 

+ 47 - 29
packages/ckeditor5-engine/src/view/renderer.js

@@ -212,44 +212,65 @@ export default class Renderer {
 			this._updateChildren( element, { inlineFillerPosition } );
 		}
 
+		// Check whether inline filler is required and where it really is in DOM. At this point in most cases it should
+		// be in DOM, but not always. For example, if inline filler was deep in created DOM structure, it will not be created.
+		// Similarly, if it was removed at the beginning of this function and then neither text nor children were updated,
+		// it will not be present. Fix those and similar scenarios.
+		if ( inlineFillerPosition ) {
+			const fillerDomPosition = this.domConverter.viewPositionToDom( inlineFillerPosition );
+			const domDocument = fillerDomPosition.parent.ownerDocument;
+
+			if ( !startsWithFiller( fillerDomPosition.parent ) ) {
+				// Filler has not been created at filler position. Create it now.
+				// Save created filler element in `this._inlineFiller`.
+				this._inlineFiller = this._applyInlineFiller( domDocument, fillerDomPosition.parent, fillerDomPosition.offset );
+			} else {
+				// Filler has been found, save it.
+				this._inlineFiller = fillerDomPosition.parent;
+			}
+		} else {
+			// There is no filler needed.
+			this._inlineFiller = null;
+		}
+
 		this._updateSelection();
 		this._updateFocus();
 
 		this.markedTexts.clear();
 		this.markedAttributes.clear();
 		this.markedChildren.clear();
-
-		// Remember the filler by its node.
-		this._inlineFiller = this._getInlineFillerNode( inlineFillerPosition );
 	}
 
 	/**
-	 * Gets the text node in which the inline filler is kept.
+	 * Applies inline filler at given position.
+	 *
+	 * The position can be given as array with DOM nodes and offset in that array, or DOM parent element and offset in that element.
 	 *
 	 * @private
-	 * @param {module:engine/view/position~Position} fillerPosition The position on which the filler is needed in the view.
-	 * @returns {Text} The text node with the filler.
+	 * @param {Document} domDocument
+	 * @param {Element|Array} domParentOrArray
+	 * @param {Number} offset
+	 * @returns {Text} The DOM text node that contains inline filler.
 	 */
-	_getInlineFillerNode( fillerPosition ) {
-		if ( !fillerPosition ) {
-			this._inlineFiller = null;
+	_applyInlineFiller( domDocument, domParentOrArray, offset ) {
+		const childNodes = domParentOrArray instanceof Array ? domParentOrArray : domParentOrArray.childNodes;
+		const nodeAfterFiller = childNodes[ offset ];
 
-			return;
-		}
+		if ( this.domConverter.isText( nodeAfterFiller ) ) {
+			nodeAfterFiller.data = INLINE_FILLER + nodeAfterFiller.data;
 
-		const domPosition = this.domConverter.viewPositionToDom( fillerPosition );
+			return nodeAfterFiller;
+		} else {
+			const fillerNode = domDocument.createTextNode( INLINE_FILLER );
 
-		/* istanbul ignore if */
-		if ( !domPosition || !startsWithFiller( domPosition.parent ) ) {
-			/**
-			 * Cannot find filler node by its position.
-			 *
-			 * @error view-renderer-cannot-find-filler
-			 */
-			throw new CKEditorError( 'view-renderer-cannot-find-filler: Cannot find filler node by its position.' );
-		}
+			if ( domParentOrArray instanceof Array ) {
+				childNodes.splice( offset, 0, fillerNode );
+			} else {
+				insertAt( domParentOrArray, offset, fillerNode );
+			}
 
-		return domPosition.parent;
+			return fillerNode;
+		}
 	}
 
 	/**
@@ -449,14 +470,11 @@ export default class Renderer {
 		const actualDomChildren = domElement.childNodes;
 		const expectedDomChildren = Array.from( domConverter.viewChildrenToDom( viewElement, domDocument, { bind: true } ) );
 
+		// Inline filler element has to be created during children update because we need it to diff actual dom
+		// elements with expected dom elements. We need inline filler in expected dom elements so we won't re-render
+		// text node if it is not necessary.
 		if ( filler && filler.parent == viewElement ) {
-			const expectedNodeAfterFiller = expectedDomChildren[ filler.offset ];
-
-			if ( this.domConverter.isText( expectedNodeAfterFiller ) ) {
-				expectedNodeAfterFiller.data = INLINE_FILLER + expectedNodeAfterFiller.data;
-			} else {
-				expectedDomChildren.splice( filler.offset, 0, domDocument.createTextNode( INLINE_FILLER ) );
-			}
+			this._applyInlineFiller( domDocument, expectedDomChildren, filler.offset );
 		}
 
 		const actions = diff( actualDomChildren, expectedDomChildren, sameNodes );

+ 94 - 2
packages/ckeditor5-engine/tests/controller/deletecontent.js

@@ -235,8 +235,8 @@ describe( 'DataController', () => {
 
 			test(
 				'leaves just one element when all selected',
-				'<heading1>[x</heading1><paragraph>foo</paragraph><paragraph>y]</paragraph>',
-				'<heading1>[]</heading1>'
+				'<heading1>[x</heading1><paragraph>foo</paragraph><paragraph>y]bar</paragraph>',
+				'<heading1>[]bar</heading1>'
 			);
 
 			it( 'uses remove delta instead of merge delta if merged element is empty', () => {
@@ -450,6 +450,8 @@ describe( 'DataController', () => {
 
 				const schema = doc.schema;
 
+				schema.limits.add( 'restrictedRoot' );
+
 				schema.registerItem( 'image', '$inline' );
 				schema.registerItem( 'paragraph', '$block' );
 				schema.registerItem( 'heading1', '$block' );
@@ -465,6 +467,8 @@ describe( 'DataController', () => {
 			// See also "in simple scenarios => deletes an element".
 
 			it( 'deletes two inline elements', () => {
+				doc.schema.limits.add( 'paragraph' );
+
 				setData(
 					doc,
 					'x[<image></image><image></image>]z',
@@ -659,6 +663,94 @@ describe( 'DataController', () => {
 			);
 		} );
 
+		describe( 'should leave a paragraph if the entire content was selected', () => {
+			beforeEach( () => {
+				doc = new Document();
+				doc.createRoot();
+
+				const schema = doc.schema;
+
+				schema.registerItem( 'div', '$block' );
+				schema.limits.add( 'div' );
+
+				schema.registerItem( 'article', '$block' );
+				schema.limits.add( 'article' );
+
+				schema.registerItem( 'image', '$inline' );
+				schema.objects.add( 'image' );
+
+				schema.registerItem( 'paragraph', '$block' );
+				schema.registerItem( 'heading1', '$block' );
+				schema.registerItem( 'heading2', '$block' );
+
+				schema.allow( { name: '$text', inside: '$root' } );
+
+				schema.allow( { name: 'image', inside: '$root' } );
+				schema.allow( { name: 'image', inside: 'heading1' } );
+				schema.allow( { name: 'heading1', inside: 'div' } );
+				schema.allow( { name: 'paragraph', inside: 'div' } );
+				schema.allow( { name: 'heading1', inside: 'article' } );
+				schema.allow( { name: 'heading2', inside: 'article' } );
+			} );
+
+			test(
+				'but not if only one block was selected',
+				'<heading1>[xx]</heading1>',
+				'<heading1>[]</heading1>'
+			);
+
+			test(
+				'when the entire heading and paragraph were selected',
+				'<heading1>[xx</heading1><paragraph>yy]</paragraph>',
+				'<paragraph>[]</paragraph>'
+			);
+
+			test(
+				'when the entire content was selected',
+				'<heading1>[x</heading1><paragraph>foo</paragraph><paragraph>y]</paragraph>',
+				'<paragraph>[]</paragraph>'
+			);
+
+			test(
+				'inside the limit element when the entire heading and paragraph were inside',
+				'<div><heading1>[xx</heading1><paragraph>yy]</paragraph></div>',
+				'<div><paragraph>[]</paragraph></div>'
+			);
+
+			test(
+				'but not if schema does not accept paragraph in limit element',
+				'<article><heading1>[xx</heading1><heading2>yy]</heading2></article>',
+				'<article><heading1>[]</heading1></article>'
+			);
+
+			test(
+				'but not if selection is not containing the whole content',
+				'<image></image><heading1>[xx</heading1><paragraph>yy]</paragraph>',
+				'<image></image><heading1>[]</heading1>'
+			);
+
+			test(
+				'but not if only single element is selected',
+				'<heading1>[<image></image>xx]</heading1>',
+				'<heading1>[]</heading1>'
+			);
+
+			it( 'when root element was not added as Schema.limits works fine as well', () => {
+				doc.createRoot( 'paragraph', 'paragraphRoot' );
+
+				setData(
+					doc,
+					'x[<image></image><image></image>]z',
+					{ rootName: 'paragraphRoot' }
+				);
+
+				deleteContent( doc.selection, doc.batch() );
+
+				expect( getData( doc, { rootName: 'paragraphRoot' } ) )
+					.to.equal( 'x[]z' );
+			} );
+		} );
+
 		function test( title, input, output, options ) {
 			it( title, () => {
 				setData( doc, input );

+ 22 - 5
packages/ckeditor5-engine/tests/controller/editingcontroller.js

@@ -29,10 +29,18 @@ import { getData as getViewData } from '../../src/dev-utils/view';
 
 describe( 'EditingController', () => {
 	describe( 'constructor()', () => {
-		it( 'should create controller with properties', () => {
-			const model = new ModelDocument();
-			const editing = new EditingController( model );
+		let model, editing;
+
+		beforeEach( () => {
+			model = new ModelDocument();
+			editing = new EditingController( model );
+		} );
+
+		afterEach( () => {
+			editing.destroy();
+		} );
 
+		it( 'should create controller with properties', () => {
 			expect( editing ).to.have.property( 'model' ).that.equals( model );
 			expect( editing ).to.have.property( 'view' ).that.is.instanceof( ViewDocument );
 			expect( editing ).to.have.property( 'mapper' ).that.is.instanceof( Mapper );
@@ -40,9 +48,18 @@ describe( 'EditingController', () => {
 
 			editing.destroy();
 		} );
+
+		it( 'should be observable', () => {
+			const spy = sinon.spy();
+
+			editing.on( 'change:foo', spy );
+			editing.set( 'foo', 'bar' );
+
+			sinon.assert.calledOnce( spy );
+		} );
 	} );
 
-	describe( 'createRoot', () => {
+	describe( 'createRoot()', () => {
 		let model, modelRoot, editing;
 
 		beforeEach( () => {
@@ -377,7 +394,7 @@ describe( 'EditingController', () => {
 		} );
 	} );
 
-	describe( 'destroy', () => {
+	describe( 'destroy()', () => {
 		it( 'should remove listenters', () => {
 			const model = new ModelDocument();
 			model.createRoot();

+ 4 - 0
packages/ckeditor5-engine/tests/model/schema/schema.js

@@ -49,6 +49,10 @@ describe( 'Schema', () => {
 			expect( schema.limits ).to.be.instanceOf( Set );
 		} );
 
+		it( 'should mark $root as a limit element', () => {
+			expect( schema.limits.has( '$root' ) ).to.be.true;
+		} );
+
 		describe( '$clipboardHolder', () => {
 			it( 'should allow $block', () => {
 				expect( schema.check( { name: '$block', inside: [ '$clipboardHolder' ] } ) ).to.be.true;

+ 1 - 0
packages/ckeditor5-engine/tests/view/_utils/createdocumentmock.js

@@ -14,6 +14,7 @@ import Selection from '../../../src/view/selection';
 export default function createDocumentMock() {
 	const doc = Object.create( ObservableMixin );
 	doc.set( 'isFocused', false );
+	doc.set( 'isReadOnly', false );
 	doc.selection = new Selection();
 
 	return doc;

+ 4 - 3
packages/ckeditor5-engine/tests/view/document/document.js

@@ -71,9 +71,10 @@ describe( 'Document', () => {
 		it( 'should create Document with all properties', () => {
 			expect( count( viewDocument.domRoots ) ).to.equal( 0 );
 			expect( count( viewDocument.roots ) ).to.equal( 0 );
-			expect( viewDocument ).to.have.property( 'renderer' ).that.is.instanceOf( Renderer );
-			expect( viewDocument ).to.have.property( 'domConverter' ).that.is.instanceOf( DomConverter );
-			expect( viewDocument ).to.have.property( 'isFocused' ).that.is.false;
+			expect( viewDocument ).to.have.property( 'renderer' ).to.instanceOf( Renderer );
+			expect( viewDocument ).to.have.property( 'domConverter' ).to.instanceOf( DomConverter );
+			expect( viewDocument ).to.have.property( 'isReadOnly' ).to.false;
+			expect( viewDocument ).to.have.property( 'isFocused' ).to.false;
 		} );
 
 		it( 'should add default observers', () => {

+ 13 - 0
packages/ckeditor5-engine/tests/view/editableelement.js

@@ -151,6 +151,19 @@ describe( 'EditableElement', () => {
 
 			expect( isReadOnlySpy.calledOnce ).to.be.true;
 		} );
+
+		it( 'should be bound to the document#isReadOnly', () => {
+			const root = new RootEditableElement( 'div' );
+			root.document = createDocumentMock();
+
+			root.document.isReadOnly = false;
+
+			expect( root.isReadOnly ).to.false;
+
+			root.document.isReadOnly = true;
+
+			expect( root.isReadOnly ).to.true;
+		} );
 	} );
 
 	describe( 'getDocument', () => {

+ 1 - 0
packages/ckeditor5-engine/tests/view/manual/keyobserver.js

@@ -11,6 +11,7 @@ import { setData } from '../../../src/dev-utils/view';
 const viewDocument = new Document();
 
 viewDocument.on( 'keydown', ( evt, data ) => console.log( 'keydown', data ) );
+viewDocument.on( 'keyup', ( evt, data ) => console.log( 'keyup', data ) );
 
 viewDocument.createRoot( document.getElementById( 'editable' ), 'editable' );
 setData( viewDocument, 'foo{}bar', { rootName: 'editable' } );

+ 2 - 0
packages/ckeditor5-engine/tests/view/manual/keyobserver.md

@@ -1,5 +1,7 @@
 * Expected initialization: `foo{}bar`.
 * Press some keys - nothing should be added to editor's contents.
+* When press some key - event `keydown` should be logged. When the key is released - event `keyup` should be logged.
+* You can hold the key in order to check whether `keydown` event is fired multiple times. After releasing the key, `keyup` event should be fired once.
 * Check whether key events are logged to the console with proper data:
   * `keyCode`,
   * `altKey`,

+ 50 - 4
packages/ckeditor5-engine/tests/view/observer/keyobserver.js

@@ -22,7 +22,8 @@ describe( 'KeyObserver', () => {
 	} );
 
 	it( 'should define domEventType', () => {
-		expect( observer.domEventType ).to.equal( 'keydown' );
+		expect( observer.domEventType ).to.contains( 'keydown' );
+		expect( observer.domEventType ).to.contains( 'keyup' );
 	} );
 
 	describe( 'onDomEvent', () => {
@@ -31,7 +32,15 @@ describe( 'KeyObserver', () => {
 
 			viewDocument.on( 'keydown', spy );
 
-			observer.onDomEvent( { target: document.body, keyCode: 111, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false } );
+			observer.onDomEvent( {
+				type: 'keydown',
+				target: document.body,
+				keyCode: 111,
+				altKey: false,
+				ctrlKey: false,
+				metaKey: false,
+				shiftKey: false
+			} );
 
 			expect( spy.calledOnce ).to.be.true;
 
@@ -52,7 +61,15 @@ describe( 'KeyObserver', () => {
 
 			viewDocument.on( 'keydown', spy );
 
-			observer.onDomEvent( { target: document.body, keyCode: 111, altKey: true, ctrlKey: true, metaKey: false, shiftKey: true } );
+			observer.onDomEvent( {
+				type: 'keydown',
+				target: document.body,
+				keyCode: 111,
+				altKey: true,
+				ctrlKey: true,
+				metaKey: false,
+				shiftKey: true
+			} );
 
 			const data = spy.args[ 0 ][ 1 ];
 			expect( data ).to.have.property( 'keyCode', 111 );
@@ -70,10 +87,39 @@ describe( 'KeyObserver', () => {
 
 			viewDocument.on( 'keydown', spy );
 
-			observer.onDomEvent( { target: document.body, keyCode: 111, metaKey: true } );
+			observer.onDomEvent( { type: 'keydown', target: document.body, keyCode: 111, metaKey: true } );
 
 			const data = spy.args[ 0 ][ 1 ];
 			expect( data ).to.have.property( 'ctrlKey', true );
 		} );
+
+		it( 'should fire keyup with the target and key info', () => {
+			const spy = sinon.spy();
+
+			viewDocument.on( 'keyup', spy );
+
+			observer.onDomEvent( {
+				type: 'keyup',
+				target: document.body,
+				keyCode: 111,
+				altKey: false,
+				ctrlKey: false,
+				metaKey: false,
+				shiftKey: false
+			} );
+
+			expect( spy.calledOnce ).to.be.true;
+
+			const data = spy.args[ 0 ][ 1 ];
+			expect( data ).to.have.property( 'domTarget', document.body );
+			expect( data ).to.have.property( 'keyCode', 111 );
+			expect( data ).to.have.property( 'altKey', false );
+			expect( data ).to.have.property( 'ctrlKey', false );
+			expect( data ).to.have.property( 'shiftKey', false );
+			expect( data ).to.have.property( 'keystroke', getCode( data ) );
+
+			// Just to be sure.
+			expect( getCode( data ) ).to.equal( 111 );
+		} );
 	} );
 } );

+ 20 - 0
packages/ckeditor5-engine/tests/view/observer/selectionobserver.js

@@ -134,6 +134,26 @@ describe( 'SelectionObserver', () => {
 		changeDomSelection();
 	} );
 
+	it( 'should fired if there is no focus but document is read-only', done => {
+		const spy = sinon.spy();
+
+		viewDocument.isFocused = false;
+		viewDocument.isReadOnly = true;
+
+		// changeDomSelection() may focus the editable element (happens on Chrome)
+		// so cancel this because it sets the isFocused flag.
+		viewDocument.on( 'focus', evt => evt.stop(), { priority: 'highest' } );
+
+		viewDocument.on( 'selectionChange', spy );
+
+		setTimeout( () => {
+			sinon.assert.calledOnce( spy );
+			done();
+		}, 70 );
+
+		changeDomSelection();
+	} );
+
 	it( 'should warn and not enter infinite loop', () => {
 		// Selectionchange event is called twice per `changeDomSelection()` execution.
 		let counter = 35;

+ 8 - 0
packages/ckeditor5-engine/tests/view/position.js

@@ -594,6 +594,14 @@ describe( 'Position', () => {
 			test( firstPosition, secondPosition, section );
 		} );
 
+		it( 'for two positions in different trees returns null', () => {
+			const div = new Element( 'div' );
+			const posInDiv = new Position( div, 0 );
+			const firstPosition = new Position( liOl2, 10 );
+
+			test( posInDiv, firstPosition, null );
+		} );
+
 		function test( positionA, positionB, lca ) {
 			expect( positionA.getCommonAncestor( positionB ) ).to.equal( lca );
 			expect( positionB.getCommonAncestor( positionA ) ).to.equal( lca );

+ 39 - 0
packages/ckeditor5-engine/tests/view/renderer.js

@@ -7,6 +7,7 @@
 
 import ViewDocument from '../../src/view/document';
 import ViewElement from '../../src/view/element';
+import ViewContainerElement from '../../src/view/containerelement';
 import ViewAttributeElement from '../../src/view/attributeelement';
 import ViewText from '../../src/view/text';
 import ViewRange from '../../src/view/range';
@@ -1216,6 +1217,44 @@ describe( 'Renderer', () => {
 			} ).to.throw( CKEditorError, /^view-renderer-filler-was-lost/ );
 		} );
 
+		// #1014.
+		it( 'should create inline filler in newly created dom nodes', () => {
+			// 1. Create the view structure which needs inline filler.
+			const inputView =
+				'<container:ul>' +
+					'<container:li>Foobar.</container:li>' +
+					'<container:li>[]<container:div></container:div></container:li>' +
+				'</container:ul>';
+
+			const { view: view, selection: newSelection } = parse( inputView );
+
+			viewRoot.appendChildren( view );
+			selection.setTo( newSelection );
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.render();
+
+			// 2. Check if filler element has been (correctly) created.
+			expect( domRoot.innerHTML.indexOf( INLINE_FILLER ) ).not.to.equal( -1 );
+
+			// 3. Move the inline filler parent to a newly created element.
+			const viewLi = view.getChild( 0 );
+			const viewLiIndented = view.removeChildren( 1, 1 ); // Array with one element.
+			const viewUl = new ViewContainerElement( 'ul', null, viewLiIndented );
+			viewLi.appendChildren( viewUl );
+
+			// 4. Mark changed items and render the view.
+			renderer.markToSync( 'children', view );
+			renderer.markToSync( 'children', viewLi );
+			renderer.render();
+
+			expect( domRoot.innerHTML.indexOf( INLINE_FILLER ) ).not.to.equal( -1 );
+
+			const domSelection = document.getSelection();
+
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 7 ); // After inline filler.
+		} );
+
 		it( 'should handle focusing element', () => {
 			const domFocusSpy = testUtils.sinon.spy( domRoot, 'focus' );
 			const editable = selection.editableElement;

+ 12 - 5
packages/ckeditor5-engine/tests/view/utils-tests/createdocumentmock.js

@@ -6,19 +6,26 @@
 import createDocumentMock from '../../../tests/view/_utils/createdocumentmock';
 
 describe( 'createDocumentMock', () => {
-	it( 'should create document mock', done => {
+	it( 'should create document mock', () => {
 		const docMock = createDocumentMock();
 		const rootMock = {};
 
+		const isFocusedSpy = sinon.spy();
+		const isReadOnlySpy = sinon.spy();
+
 		docMock.on( 'change:selectedEditable', ( evt, key, value ) => {
 			expect( value ).to.equal( rootMock );
 		} );
 
-		docMock.on( 'change:isFocused', ( evt, key, value ) => {
-			expect( value ).to.be.true;
-			done();
-		} );
+		docMock.on( 'change:isFocused', isFocusedSpy );
+		docMock.on( 'change:isReadOnly', isReadOnlySpy );
 
 		docMock.isFocused = true;
+		docMock.isReadOnly = true;
+
+		sinon.assert.calledOnce( isFocusedSpy );
+		expect( isFocusedSpy.lastCall.args[ 2 ] ).to.true;
+		sinon.assert.calledOnce( isReadOnlySpy );
+		expect( isReadOnlySpy.lastCall.args[ 2 ] ).to.true;
 	} );
 } );