Explorar el Código

Merge pull request #442 from ckeditor/t/288

t/288: Implement Editing controller.
Piotrek Koszuliński hace 9 años
padre
commit
a75c5290e9

+ 43 - 0
packages/ckeditor5-engine/src/conversion/view-selection-to-model-converters.js

@@ -0,0 +1,43 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+/**
+ * Contains {@link engine.view.Selection view selection} to {@link engine.model.Selection model selection} conversion
+ * helper.
+ *
+ * @namespace engine.conversion.viewSelectionToModel
+ */
+
+/**
+ * Function factory, creates a callback function which converts a {@link engine.view.Selection view selection} taken
+ * from the {@link engine.view.Document#selectionChange} event and set in on the
+ * {@link engine.model.Document#selection model}.
+ *
+ * Note that because there is not view selection change dispatcher nor any other advance view selection to model
+ * conversion mechanism, this method is simple event listener.
+ *
+ *		view.document.on( 'selectionChange', convertSelectionChange( model, mapper ) );
+ *
+ * @function engine.conversion.viewSelectionToModel.convertSelectionChange
+ * @param {engine.model.Document} model Document model on which selection should be updated.
+ * @param {engine.conversion.Mapper} mapper Conversion mapper.
+ * @returns {Function} {@link engine.view.Document#selectionChange} callback function.
+ */
+export function convertSelectionChange( model, mapper ) {
+	return ( evt, data ) => {
+		model.enqueueChanges( () => {
+			const viewSelection = data.newSelection;
+
+			model.selection.removeAllRanges();
+
+			for ( let viewRange of viewSelection.getRanges() ) {
+				const modelRange = mapper.toModelRange( viewRange );
+				model.selection.addRange( modelRange, viewSelection.isBackward );
+			}
+		} );
+	};
+}

+ 5 - 6
packages/ckeditor5-engine/src/datacontroller.js

@@ -37,18 +37,17 @@ export default class DataController {
 	/**
 	 * Creates data controller instance.
 	 *
-	 *
-	 * @param {engine.model.Document} modelDocument Model document.
+	 * @param {engine.model.Document} model Document model.
 	 * @param {engine.dataProcessor.DataProcessor} dataProcessor Data processor which should used by the controller.
 	 */
-	constructor( modelDocument, dataProcessor ) {
+	constructor( model, dataProcessor ) {
 		/**
-		 * Model document.
+		 * Document model.
 		 *
 		 * @readonly
 		 * @member {engine.model.document} engine.DataController#model
 		 */
-		this.model = modelDocument;
+		this.model = model;
 
 		/**
 		 * Data processor used during the conversion.
@@ -117,7 +116,7 @@ export default class DataController {
 		 * @member {engine.conversion.ViewConversionDispatcher} engine.DataController#viewToModel
 		 */
 		this.viewToModel = new ViewConversionDispatcher( {
-			schema: modelDocument.schema
+			schema: model.schema
 		} );
 
 		// Define default converters for text and elements.

+ 149 - 3
packages/ckeditor5-engine/src/editingcontroller.js

@@ -6,12 +6,158 @@
 'use strict';
 
 import ViewDocument from './view/document.js';
+import MutationObserver from './view/observer/mutationobserver.js';
+import SelectionObserver from './view/observer/selectionobserver.js';
+import FocusObserver from './view/observer/focusobserver.js';
+import KeyObserver from './view/observer/keyobserver.js';
 
+import Mapper from './conversion/mapper.js';
+import ModelConversionDispatcher from './conversion/modelconversiondispatcher.js';
+import { insertText, remove, move } from './conversion/model-to-view-converters.js';
+import { convertSelectionChange } from './conversion/view-selection-to-model-converters.js';
+import {
+	convertRangeSelection,
+	convertCollapsedSelection,
+	clearAttributes
+} from './conversion/model-selection-to-view-converters.js';
+
+import EmitterMixin from '../utils/emittermixin.js';
+
+/**
+ * Controller for the editing pipeline. The editing pipeline controls {@link engine.EditingController#model model} rendering,
+ * including selection handling. It also creates {@link engine.EditingController#view view document} which build a
+ * browser-independent virtualization over the DOM elements. Editing controller also attach default converters and
+ * observers.
+ *
+ * Note that the following observers are attached by the controller and are always available:
+ *
+ * * {@link view.observer.MutationObserver},
+ * * {@link view.observer.SelectionObserver},
+ * * {@link view.observer.FocusObserver},
+ * * {@link view.observer.KeyObserver}.
+ *
+ * @memberOf engine
+ */
 export default class EditingController {
-	constructor( modelDocument ) {
-		this.model = modelDocument;
+	/**
+	 * Creates editing controller instance.
+	 *
+	 * @param {engine.model.Document} model Document model.
+	 */
+	constructor( model ) {
+		/**
+		 * Document model.
+		 *
+		 * @readonly
+		 * @member {engine.model.document} engine.EditingController#model
+		 */
+		this.model = model;
+
+		/**
+		 * View document.
+		 *
+		 * @readonly
+		 * @member {engine.view.document} engine.EditingController#view
+		 */
 		this.view = new ViewDocument();
+
+		// Attach default observers.
+		this.view.addObserver( MutationObserver );
+		this.view.addObserver( SelectionObserver );
+		this.view.addObserver( FocusObserver );
+		this.view.addObserver( KeyObserver );
+
+		/**
+		 * Mapper which describes model-view binding.
+		 *
+		 * @readonly
+		 * @member {engine.conversion.Mapper} engine.EditingController#mapper
+		 */
+		this.mapper = new Mapper();
+
+		/**
+		 * Model to view conversion dispatcher, which converts changes from the model to
+		 * {@link engine.EditingController#view editing view}.
+		 *
+		 * To attach model to view converter to the editing pipeline you need to add lister to this property:
+		 *
+		 *		editing.modelToView( 'insert:$element', customInsertConverter );
+		 *
+		 * Or use {@link engine.conversion.ModelConverterBuilder}:
+		 *
+		 *		BuildModelConverterFor( editing.modelToView ).fromAttribute( 'bold' ).toElement( 'b' );
+		 *
+		 * @readonly
+		 * @member {engine.conversion.ModelConversionDispatcher} engine.EditingController#modelToView
+		 */
+		this.modelToView = new ModelConversionDispatcher( {
+			writer: this.view.writer,
+			mapper: this.mapper,
+			viewSelection: this.view.selection
+		} );
+
+		/**
+		 * Property keeping all listenters attached by controller on other objects, so it can
+		 * stop listening on {@link engine.EditingController#destroy}.
+		 *
+		 * @private
+		 * @member {utils.EmitterMixin} engine.EditingController#_listenter
+		 */
+		this._listenter = Object.create( EmitterMixin );
+
+		// Convert view selection to model.
+		this._listenter.listenTo( this.view, 'selectionChange', convertSelectionChange( model, this.mapper ) );
+
+		this._listenter.listenTo( this.model, 'change', ( evt, type, changes ) => {
+			this.modelToView.convertChange( type, changes );
+		} );
+
+		this._listenter.listenTo( this.model, 'changesDone', () => {
+			this.modelToView.convertSelection( model.selection );
+			this.view.render();
+		} );
+
+		// Attach default content converters.
+		this.modelToView.on( 'insert:$text', insertText() );
+		this.modelToView.on( 'remove', remove() );
+		this.modelToView.on( 'move', move() );
+
+		// Attach default selection converters.
+		this.modelToView.on( 'selection', clearAttributes() );
+		this.modelToView.on( 'selection', convertRangeSelection() );
+		this.modelToView.on( 'selection', convertCollapsedSelection() );
 	}
 
-	destroy() {}
+	/**
+	 * {@link engine.view.Document#createRoot Creates} a view root and {@link engine.conversion.Mapper#bindElements binds}
+	 * the model root with view root and and view root with DOM element:
+	 *
+	 *		editing.createRoot( document.querySelector( div#editor ) );
+	 *
+	 * If the DOM element is not available at the time you want to create a view root, for instance it is iframe body
+	 * element, it is possible to create view element and bind the DOM element later:
+	 *
+	 *		editing.createRoot( 'body' );
+	 *		editing.view.attachDomRoot( iframe.contentDocument.body );
+	 *
+	 * @param {Element|String} domRoot DOM root element or the name of view root element if the DOM element will be
+	 * attached later.
+	 * @param {String} [name='main'] Root name.
+	 * @returns {engine.view.ContainerElement} View root element.
+	 */
+	createRoot( domRoot, name = 'main' ) {
+		const viewRoot = this.view.createRoot( domRoot, name );
+		const modelRoot = this.model.getRoot( name );
+
+		this.mapper.bindElements( modelRoot, viewRoot );
+
+		return viewRoot;
+	}
+
+	/**
+	 * Removes all event listeners attached by the EditingController.
+	 */
+	destroy() {
+		this._listenter.stopListening();
+	}
 }

+ 4 - 4
packages/ckeditor5-engine/src/model/document.js

@@ -181,13 +181,13 @@ export default class Document {
 	/**
 	 * Creates a new top-level root.
 	 *
-	 * @param {String} rootName Unique root name.
+	 * @param {String} [rootName='main'] Unique root name.
 	 * @param {String} [elementName='$root'] Element name. Defaults to `'$root'` which also have
 	 * some basic schema defined (`$block`s are allowed inside the `$root`). Make sure to define a proper
 	 * schema if you use a different name.
 	 * @returns {engine.model.RootElement} Created root.
 	 */
-	createRoot( rootName, elementName = '$root' ) {
+	createRoot( rootName = 'main', elementName = '$root' ) {
 		if ( this._roots.has( rootName ) ) {
 			/**
 			 * Root with specified name already exists.
@@ -243,10 +243,10 @@ export default class Document {
 	/**
 	 * Returns top-level root by its name.
 	 *
-	 * @param {String|Symbol} name Unique root name.
+	 * @param {String} [name='main'] Unique root name.
 	 * @returns {engine.model.RootElement} Root registered under given name.
 	 */
-	getRoot( name ) {
+	getRoot( name = 'main' ) {
 		if ( !this._roots.has( name ) ) {
 			/**
 			 * Root with specified name does not exist.

+ 58 - 16
packages/ckeditor5-engine/src/view/document.js

@@ -9,6 +9,7 @@ import Selection from './selection.js';
 import Renderer from './renderer.js';
 import Writer from './writer.js';
 import DomConverter from './domconverter.js';
+import ContainerElement from './containerelement.js';
 import { injectQuirksHandling } from './filler.js';
 
 import mix from '../../utils/mix.js';
@@ -135,50 +136,91 @@ export default class Document {
 	}
 
 	/**
-	 * Creates a root for the HTMLElement. It adds elements to {@link engine.view.Document#domRoots} and
-	 * {@link engine.view.Document#viewRoots}.
+	 * Creates a {@link engine.view.Document#viewRoots view root element}.
 	 *
-	 * The constructor copies the element name and attributes to create the
-	 * root of the view, but does not copy its children. This means that while
-	 * {@link engine.view.Document#render rendering}, the whole content of this
-	 * root element will be removed but the root name and attributes will be preserved.
+	 * If the DOM element is passed as a first parameter it will be automatically
+	 * {@link engine.view.Document#attachDomRoot attached}:
 	 *
-	 * @param {HTMLElement} domRoot DOM element in which the tree view should do change.
+	 *		document.createRoot( document.querySelector( 'div#editor' ) ); // Will call document.attachDomRoot.
+	 *
+	 * However, if the string is passed, then only the view element will be created and the DOM element have to be
+	 * attached separately:
+	 *
+	 *		document.createRoot( 'body' );
+	 *		document.attachDomRoot( document.querySelector( 'body#editor' ) );
+	 *
+	 * @param {Element|String} domRoot DOM root element or the tag name of view root element if the DOM element will be
+	 * attached later.
 	 * @param {String} [name='main'] Name of the root.
-	 * @returns {engine.view.element} The created view root element.
+	 * @returns {engine.view.ContainerElement} The created view root element.
 	 */
 	createRoot( domRoot, name = 'main' ) {
-		const viewRoot = this.domConverter.domToView( domRoot, { bind: true, withChildren: false } );
+		const rootTag = typeof domRoot == 'string' ? domRoot : domRoot.tagName;
+
+		const viewRoot = new ContainerElement( rootTag );
 		viewRoot.setDocument( this );
 
+		this.viewRoots.set( name, viewRoot );
+
 		// Mark changed nodes in the renderer.
 		viewRoot.on( 'change', ( evt, type, node ) => {
 			this.renderer.markToSync( type, node );
 		} );
-		this.renderer.markToSync( 'CHILDREN', viewRoot );
+
+		if ( domRoot instanceof HTMLElement ) {
+			this.attachDomRoot( domRoot, name );
+		}
+
+		return viewRoot;
+	}
+
+	/**
+	 * Attaches DOM root element to the view element and enable all observers on that element. This method also
+	 * {@link engine.view.Renderer#markToSync mark element} to be synchronized with the view what means that all child
+	 * nodes will be removed and replaced with content of the view root.
+	 *
+	 * Note that {@link engine.view.Document#createRoot} will call this method automatically if the DOM element is
+	 * passed to it.
+	 *
+	 * @param {Element|String} domRoot DOM root element.
+	 * @param {String} [name='main'] Name of the root.
+	 */
+	attachDomRoot( domRoot, name = 'main' ) {
+		const viewRoot = this.getRoot( name );
 
 		this.domRoots.set( name, domRoot );
-		this.viewRoots.set( name, viewRoot );
+
+		this.domConverter.bindElements( domRoot, viewRoot );
+
+		this.renderer.markToSync( 'CHILDREN', viewRoot );
 
 		for ( let observer of this._observers.values() ) {
 			observer.observe( domRoot, name );
 		}
-
-		return viewRoot;
 	}
 
 	/**
-	 * Get a {@link engine.view.Document#viewRoots view root element} with the specified name. If the name is not
+	 * Gets a {@link engine.view.Document#viewRoots view root element} with the specified name. If the name is not
 	 * specific "main" root is returned.
 	 *
-	 * @param {String} [name='main']  Name of the root.
-	 * @returns {engine.view.element} The view root element with the specified name.
+	 * @param {String} [name='main'] Name of the root.
+	 * @returns {engine.view.ContainerElement} The view root element with the specified name.
 	 */
 	getRoot( name = 'main' ) {
 		return this.viewRoots.get( name );
 	}
 
 	/**
+	 * Gets DOM root element.
+	 *
+	 * @param {String} [name='main']  Name of the root.
+	 * @returns {Element} DOM root element instance.
+	 */
+	getDomRoot( name = 'main' ) {
+		return this.domRoots.get( name );
+	}
+
+	/**
 	 * Renders all changes. In order to avoid triggering the observers (e.g. mutations) all observers all detached
 	 * before rendering and reattached after that.
 	 */

+ 14 - 0
packages/ckeditor5-engine/src/view/observer/focusobserver.js

@@ -10,6 +10,8 @@ import DomEventObserver from './domeventobserver.js';
 /**
  * {@link engine.view.Document#focus Focus} and {@link engine.view.Document#blur blur} events observer.
  *
+ * Note that this observer is attached by the {@link engine.EditingController} and is available by default.
+ *
  * @memberOf engine.view.observer
  * @extends engine.view.observer.DomEventObserver
  */
@@ -28,6 +30,12 @@ export default class FocusObserver extends DomEventObserver {
 /**
  * Fired when one of the editables gets focus.
  *
+ * Introduced by {@link engine.view.observer.FocusObserver}.
+ *
+ * Note that because {@link engine.view.observer.FocusObserver} is attached by the {@link engine.EditingController}
+ * this event is available by default.
+ *
+ * @see engine.view.observer.FocusObserver
  * @event engine.view.Document#focus
  * @param {engine.view.observer.DomEventData} data Event data.
  */
@@ -35,6 +43,12 @@ export default class FocusObserver extends DomEventObserver {
 /**
  * Fired when one of the editables loses focus.
  *
+ * Introduced by {@link engine.view.observer.FocusObserver}.
+ *
+ * Note that because {@link engine.view.observer.FocusObserver} is attached by the {@link engine.EditingController}
+ * this event is available by default.
+ *
+ * @see engine.view.observer.FocusObserver
  * @event engine.view.Document#blur
  * @param {engine.view.observer.DomEventData} data Event data.
  */

+ 8 - 0
packages/ckeditor5-engine/src/view/observer/keyobserver.js

@@ -11,6 +11,8 @@ import { getCode } from '../../../utils/keyboard.js';
 /**
  * {@link engine.view.Document#keydown Key down} event observer.
  *
+ * Note that this observer is attached by the {@link engine.EditingController} and is available by default.
+ *
  * @memberOf engine.view.observer
  * @extends engine.view.observer.DomEventObserver
  */
@@ -39,6 +41,12 @@ export default class KeyObserver extends DomEventObserver {
 /**
  * Fired when a key has been pressed.
  *
+ * Introduced by {@link engine.view.observer.KeyObserver}.
+ *
+ * Note that because {@link engine.view.observer.KeyObserver} is attached by the {@link engine.EditingController}
+ * this event is available by default.
+ *
+ * @see engine.view.observer.KeyObserver
  * @event engine.view.Document#keydown
  * @param {engine.view.observer.keyObserver.KeyEventData} keyEventData
  */

+ 8 - 0
packages/ckeditor5-engine/src/view/observer/mutationobserver.js

@@ -19,6 +19,8 @@ import { startsWithFiller, getDataWithoutFiller } from '../filler.js';
  * mutations on elements which do not have corresponding view elements. Also
  * {@link engine.view.Document.MutatatedText text mutation} is fired only if parent element do not change child list.
  *
+ * Note that this observer is attached by the {@link engine.EditingController} and is available by default.
+ *
  * @memberOf engine.view.observer
  * @extends engine.view.observer.Observer
  */
@@ -201,6 +203,12 @@ export default class MutationObserver extends Observer {
  * Fired when mutation occurred. If tree view is not changed on this event, DOM will be reverter to the state before
  * mutation, so all changes which should be applied, should be handled on this event.
  *
+ * Introduced by {@link engine.view.observer.MutationObserver}.
+ *
+ * Note that because {@link engine.view.observer.MutationObserver} is attached by the {@link engine.EditingController}
+ * this event is available by default.
+ *
+ * @see engine.view.observer.MutationObserver
  * @event engine.view.Document#mutations
  * @param {Array.<engine.view.Document~MutatatedText|engine.view.Document~MutatatedChildren>} viewMutations
  * Array of mutations.

+ 8 - 0
packages/ckeditor5-engine/src/view/observer/selectionobserver.js

@@ -15,6 +15,8 @@ import MutationObserver from './mutationobserver.js';
  * {@link engine.view.Document#selectionChange} event only if selection change was the only change in the document
  * and DOM selection is different then the view selection.
  *
+ * Note that this observer is attached by the {@link engine.EditingController} and is available by default.
+ *
  * @see engine.view.MutationObserver
  * @memberOf engine.view.observer
  * @extends engine.view.observer.Observer
@@ -122,6 +124,12 @@ export default class SelectionObserver extends Observer {
  * Fired when selection has changed. This event is fired only when the selection change was the only change that happened
  * in the document, and old selection is different then the new selection.
  *
+ * Introduced by {@link engine.view.observer.SelectionObserver}.
+ *
+ * Note that because {@link engine.view.observer.SelectionObserver} is attached by the {@link engine.EditingController}
+ * this event is available by default.
+ *
+ * @see engine.view.observer.SelectionObserver
  * @event engine.view.Document#selectionChange
  * @param {Object} data
  * @param {engine.view.Selection} data.oldSelection Old View selection which is

+ 1 - 0
packages/ckeditor5-engine/tests/__template__.html

@@ -0,0 +1 @@
+<div contenteditable="true" id="editor"></div>

+ 92 - 0
packages/ckeditor5-engine/tests/conversion/view-selection-to-model-converters.js

@@ -0,0 +1,92 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: conversion */
+
+'use strict';
+
+import ViewDocument from '/ckeditor5/engine/view/document.js';
+import ViewSelection from '/ckeditor5/engine/view/selection.js';
+import ViewRange from '/ckeditor5/engine/view/range.js';
+
+import ModelDocument from '/ckeditor5/engine/model/document.js';
+
+import Mapper from '/ckeditor5/engine/conversion/mapper.js';
+import { convertSelectionChange } from '/ckeditor5/engine/conversion/view-selection-to-model-converters.js';
+
+import { setData as modelSetData, getData as modelGetData } from '/tests/engine/_utils/model.js';
+import { setData as viewSetData } from '/tests/engine/_utils/view.js';
+
+describe( 'convertSelectionChange', () => {
+	let model, view, mapper, convertSelection, modelRoot, viewRoot;
+
+	beforeEach( () => {
+		model = new ModelDocument();
+		modelRoot = model.createRoot();
+
+		modelSetData( model, '<paragraph>foo</paragraph><paragraph>bar</paragraph>' );
+
+		view = new ViewDocument();
+		viewRoot = view.createRoot( 'div' );
+
+		viewSetData( view, '<p>foo</p><p>bar</p>' );
+
+		mapper = new Mapper();
+		mapper.bindElements( modelRoot, viewRoot );
+		mapper.bindElements( modelRoot.getChild( 0 ), viewRoot.getChild( 0 ) );
+		mapper.bindElements( modelRoot.getChild( 1 ), viewRoot.getChild( 1 ) );
+
+		convertSelection = convertSelectionChange( model, mapper );
+	} );
+
+	it( 'should convert collapsed selection', () => {
+		const viewSelection = new ViewSelection();
+		viewSelection.addRange( ViewRange.createFromParentsAndOffsets(
+			viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 1 ) );
+
+		convertSelection( null, { newSelection: viewSelection } );
+
+		expect( modelGetData( model ) ).to.equals( '<paragraph>f<selection />oo</paragraph><paragraph>bar</paragraph>' );
+	} );
+
+	it( 'should convert multi ranges selection', () => {
+		const viewSelection = new ViewSelection();
+		viewSelection.addRange( ViewRange.createFromParentsAndOffsets(
+			viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 2 ) );
+		viewSelection.addRange( ViewRange.createFromParentsAndOffsets(
+			viewRoot.getChild( 1 ).getChild( 0 ), 1, viewRoot.getChild( 1 ).getChild( 0 ), 2 ) );
+
+		convertSelection( null, { newSelection: viewSelection } );
+
+		// Too bad getData shows only the first range.
+		expect( modelGetData( model ) ).to.equals(
+			'<paragraph>f<selection>o</selection>o</paragraph><paragraph>bar</paragraph>' );
+
+		const ranges = Array.from( model.selection.getRanges() );
+		expect( ranges.length ).to.equals( 2 );
+
+		expect( ranges[ 0 ].start.parent ).to.equals( modelRoot.getChild( 0 ) );
+		expect( ranges[ 0 ].start.offset ).to.equals( 1 );
+		expect( ranges[ 0 ].end.parent ).to.equals( modelRoot.getChild( 0 ) );
+		expect( ranges[ 0 ].end.offset ).to.equals( 2 );
+
+		expect( ranges[ 1 ].start.parent ).to.equals( modelRoot.getChild( 1 ) );
+		expect( ranges[ 1 ].start.offset ).to.equals( 1 );
+		expect( ranges[ 1 ].end.parent ).to.equals( modelRoot.getChild( 1 ) );
+		expect( ranges[ 1 ].end.offset ).to.equals( 2 );
+	} );
+
+	it( 'should convert revers selection', () => {
+		const viewSelection = new ViewSelection();
+		viewSelection.addRange( ViewRange.createFromParentsAndOffsets(
+			viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 2 ), true );
+
+		convertSelection( null, { newSelection: viewSelection } );
+
+		// Too bad getData shows only the first range.
+		expect( modelGetData( model ) ).to.equals(
+			'<paragraph>f<selection backward>o</selection>o</paragraph><paragraph>bar</paragraph>' );
+	} );
+} );

+ 264 - 0
packages/ckeditor5-engine/tests/editingcontroller.js

@@ -0,0 +1,264 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: view */
+
+'use strict';
+
+import EditingController from '/ckeditor5/engine/editingcontroller.js';
+
+import ViewDocument from '/ckeditor5/engine/view/document.js';
+import MutationObserver from '/ckeditor5/engine/view/observer/mutationobserver.js';
+import SelectionObserver from '/ckeditor5/engine/view/observer/selectionobserver.js';
+import FocusObserver from '/ckeditor5/engine/view/observer/focusobserver.js';
+import KeyObserver from '/ckeditor5/engine/view/observer/keyobserver.js';
+
+import Mapper from '/ckeditor5/engine/conversion/mapper.js';
+import ModelConversionDispatcher from '/ckeditor5/engine/conversion/modelconversiondispatcher.js';
+import BuildModelConverterFor from '/ckeditor5/engine/conversion/model-converter-builder.js';
+
+import ModelDocument from '/ckeditor5/engine/model/document.js';
+import ModelPosition from '/ckeditor5/engine/model/position.js';
+import ModelRange from '/ckeditor5/engine/model/range.js';
+import ModelDocumentFragment from '/ckeditor5/engine/model/documentfragment.js';
+
+import createElement from '/ckeditor5/utils/dom/createelement.js';
+
+import { parse, getData as getModelData } from '/tests/engine/_utils/model.js';
+import { getData as getViewData } from '/tests/engine/_utils/view.js';
+
+describe( 'EditingController', () => {
+	describe( 'constructor', () => {
+		let model, editing;
+
+		beforeEach( () => {
+			model = new ModelDocument();
+			editing = new EditingController( model );
+		} );
+
+		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 );
+			expect( editing ).to.have.property( 'modelToView' ).that.is.instanceof( ModelConversionDispatcher );
+		} );
+
+		it( 'should add observers', () => {
+			expect( editing.view.getObserver( MutationObserver ) ).to.be.instanceof( MutationObserver );
+			expect( editing.view.getObserver( SelectionObserver ) ).to.be.instanceof( SelectionObserver );
+			expect( editing.view.getObserver( FocusObserver ) ).to.be.instanceof( FocusObserver );
+			expect( editing.view.getObserver( KeyObserver ) ).to.be.instanceof( KeyObserver );
+		} );
+	} );
+
+	describe( 'createRoot', () => {
+		let model, modelRoot, editing;
+
+		beforeEach( () => {
+			model = new ModelDocument();
+			modelRoot = model.createRoot();
+			model.createRoot( 'header' );
+
+			editing = new EditingController( model );
+		} );
+
+		it( 'should create root', () => {
+			const domRoot = createElement( document, 'div', null, createElement( document, 'p' ) );
+
+			const viewRoot = editing.createRoot( domRoot );
+
+			expect( viewRoot ).to.equal( editing.view.getRoot() );
+			expect( domRoot ).to.equal( editing.view.getDomRoot() );
+
+			expect( editing.view.domConverter.getCorrespondingDom( viewRoot ) ).to.equal( domRoot );
+			expect( editing.view.renderer.markedChildren.has( viewRoot ) ).to.be.true;
+
+			expect( editing.mapper.toModelElement( viewRoot ) ).to.equal( modelRoot );
+			expect( editing.mapper.toViewElement( modelRoot ) ).to.equal( viewRoot );
+		} );
+
+		it( 'should create root with given name', () => {
+			const domRoot = createElement( document, 'div', null, createElement( document, 'p' ) );
+
+			const viewRoot = editing.createRoot( domRoot, 'header' );
+
+			expect( viewRoot ).to.equal( editing.view.getRoot( 'header' ) );
+			expect( domRoot ).to.equal( editing.view.getDomRoot( 'header' ) );
+
+			expect( editing.view.domConverter.getCorrespondingDom( viewRoot ) ).to.equal( domRoot );
+			expect( editing.view.renderer.markedChildren.has( viewRoot ) ).to.be.true;
+
+			expect( editing.mapper.toModelElement( viewRoot ) ).to.equal( model.getRoot( 'header' ) );
+			expect( editing.mapper.toViewElement( model.getRoot( 'header' ) ) ).to.equal( viewRoot );
+		} );
+
+		it( 'should be possible to attach DOM element later', () => {
+			const domRoot = createElement( document, 'div', null, createElement( document, 'p' ) );
+
+			const viewRoot = editing.createRoot( 'div' );
+
+			expect( viewRoot ).to.equal( editing.view.getRoot() );
+			expect( editing.view.getDomRoot() ).to.be.undefined;
+
+			editing.view.attachDomRoot( domRoot );
+
+			expect( domRoot ).to.equal( editing.view.getDomRoot() );
+
+			expect( editing.view.domConverter.getCorrespondingDom( viewRoot ) ).to.equal( domRoot );
+			expect( editing.view.renderer.markedChildren.has( viewRoot ) ).to.be.true;
+
+			expect( editing.mapper.toModelElement( viewRoot ) ).to.equal( modelRoot );
+			expect( editing.mapper.toViewElement( modelRoot ) ).to.equal( viewRoot );
+		} );
+	} );
+
+	describe( 'conversion', () => {
+		let model, modelRoot, viewRoot, domRoot, editing;
+
+		before( () => {
+			model = new ModelDocument();
+			modelRoot = model.createRoot();
+
+			editing = new EditingController( model );
+
+			domRoot = document.getElementById( 'editor' );
+			viewRoot = editing.createRoot( domRoot );
+
+			model.schema.registerItem( 'paragraph', '$block' );
+			BuildModelConverterFor( editing.modelToView ).fromElement( 'paragraph' ).toElement( 'p' );
+		} );
+
+		beforeEach( () => {
+			model.selection.removeAllRanges();
+			modelRoot.removeChildren( 0, modelRoot.getChildCount() );
+
+			viewRoot.removeChildren( 0, viewRoot.getChildCount() );
+
+			const modelData = new ModelDocumentFragment( parse(
+				'<paragraph>foo</paragraph>' +
+				'<paragraph></paragraph>' +
+				'<paragraph>bar</paragraph>'
+			)._children );
+
+			model.enqueueChanges( () => {
+				model.batch().insert( ModelPosition.createAt( model.getRoot(), 0 ), modelData );
+				model.selection.addRange( ModelRange.createFromParentsAndOffsets(
+					modelRoot.getChild( 0 ), 1, modelRoot.getChild( 0 ), 1 ) );
+			} );
+		} );
+
+		it( 'should convert insertion', () => {
+			expect( getViewData( editing.view ) ).to.equal( '<p>f{}oo</p><p></p><p>bar</p>' );
+		} );
+
+		it( 'should convert split', () => {
+			expect( getViewData( editing.view ) ).to.equal( '<p>f{}oo</p><p></p><p>bar</p>' );
+
+			model.enqueueChanges( () => {
+				model.batch().split( model.selection.getFirstPosition() );
+				model.selection.setRanges( [
+					ModelRange.createFromParentsAndOffsets(	modelRoot.getChild( 1 ), 0, modelRoot.getChild( 1 ), 0 )
+				] );
+			} );
+
+			expect( getViewData( editing.view ) ).to.equal( '<p>f</p><p>{}oo</p><p></p><p>bar</p>' );
+		} );
+
+		it( 'should convert delete', () => {
+			model.enqueueChanges( () => {
+				model.batch().remove(
+					ModelRange.createFromPositionAndShift( model.selection.getFirstPosition(), 1 )
+				);
+				model.selection.setRanges( [
+					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 1, modelRoot.getChild( 0 ), 1 )
+				] );
+			} );
+
+			expect( getViewData( editing.view ) ).to.equal( '<p>f{}o</p><p></p><p>bar</p>' );
+		} );
+
+		it( 'should convert selection from view to model', ( done ) => {
+			editing.view.on( 'selectionChange', () => {
+				setTimeout( () => {
+					expect( getModelData( model ) ).to.equal(
+						'<paragraph>foo</paragraph>' +
+						'<paragraph></paragraph>' +
+						'<paragraph>b<selection>a</selection>r</paragraph>' );
+					done();
+				} );
+			} );
+
+			const domSelection = document.getSelection();
+			domSelection.removeAllRanges();
+			const domBar = domRoot.childNodes[ 2 ].childNodes[ 0 ];
+			const domRange = new Range();
+			domRange.setStart( domBar, 1 );
+			domRange.setEnd( domBar, 2 );
+			domSelection.addRange( domRange );
+		} );
+
+		it( 'should convert collapsed selection', () => {
+			model.enqueueChanges( () => {
+				model.selection.setRanges( [
+					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 2 ), 1, modelRoot.getChild( 2 ), 1 )
+				] );
+			} );
+
+			expect( getViewData( editing.view ) ).to.equal( '<p>foo</p><p></p><p>b{}ar</p>' );
+		} );
+
+		it( 'should convert not collapsed selection', () => {
+			model.enqueueChanges( () => {
+				model.selection.setRanges( [
+					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 2 ), 1, modelRoot.getChild( 2 ), 2 )
+				] );
+			} );
+
+			expect( getViewData( editing.view ) ).to.equal( '<p>foo</p><p></p><p>b{a}r</p>' );
+		} );
+
+		it( 'should clear previous selection', () => {
+			model.enqueueChanges( () => {
+				model.selection.setRanges( [
+					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 2 ), 1, modelRoot.getChild( 2 ), 1 )
+				] );
+			} );
+
+			expect( getViewData( editing.view ) ).to.equal( '<p>foo</p><p></p><p>b{}ar</p>' );
+
+			model.enqueueChanges( () => {
+				model.selection.setRanges( [
+					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 2 ), 2, modelRoot.getChild( 2 ), 2 )
+				] );
+			} );
+
+			expect( getViewData( editing.view ) ).to.equal( '<p>foo</p><p></p><p>ba{}r</p>' );
+		} );
+	} );
+
+	describe( 'destroy', () => {
+		it( 'should remove listenters', () => {
+			let model, editing;
+
+			model = new ModelDocument();
+			model.createRoot();
+
+			editing = new EditingController( model );
+
+			const spy = sinon.spy();
+
+			editing.modelToView.on( 'insert:$element', spy );
+
+			editing.destroy();
+
+			model.enqueueChanges( () => {
+				const modelData = parse( '<paragraph>foo</paragraph>' ).getChild( 0 );
+				model.batch().insert( ModelPosition.createAt( model.getRoot(), 0 ), modelData );
+			} );
+
+			expect( spy.called ).to.be.false;
+		} );
+	} );
+} );

+ 1 - 0
packages/ckeditor5-engine/tests/manual/editingcontroller.html

@@ -0,0 +1 @@
+<div contenteditable="true" id="editor"></div>

+ 56 - 0
packages/ckeditor5-engine/tests/manual/editingcontroller.js

@@ -0,0 +1,56 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import EditingController from '/ckeditor5/engine/editingcontroller.js';
+import Document from '/ckeditor5/engine/model/document.js';
+import ModelPosition from '/ckeditor5/engine/model/position.js';
+import ModelRange from '/ckeditor5/engine/model/range.js';
+import ModelDocumentFragment from '/ckeditor5/engine/model/documentfragment.js';
+
+import { parse } from '/tests/engine/_utils/model.js';
+
+import BuildModelConverterFor from '/ckeditor5/engine/conversion/model-converter-builder.js';
+
+const model = new Document();
+window.model = model;
+const modelRoot = model.createRoot();
+
+const editing = new EditingController( model );
+editing.createRoot( document.getElementById( 'editor' ) );
+
+model.schema.registerItem( 'paragraph', '$block' );
+BuildModelConverterFor( editing.modelToView ).fromElement( 'paragraph' ).toElement( 'p' );
+
+const modelData = new ModelDocumentFragment( parse(
+	'<paragraph>foo</paragraph>' +
+	'<paragraph></paragraph>' +
+	'<paragraph>bar</paragraph>'
+)._children );
+
+model.enqueueChanges( () => {
+	model.batch().insert( ModelPosition.createAt( modelRoot, 0 ), modelData );
+	model.selection.addRange( ModelRange.createFromParentsAndOffsets(
+		modelRoot.getChild( 0 ), 0, modelRoot.getChild( 0 ), 0 ) );
+} );
+
+// enter
+editing.view.on( 'keydown', ( evt, data ) => {
+	if ( data.keyCode == 13 ) {
+		model.enqueueChanges( () => {
+			model.batch().split( model.selection.getFirstPosition() );
+		} );
+	}
+} );
+
+// delete
+editing.view.on( 'keydown', ( evt, data ) => {
+	if ( data.keyCode == 46 ) {
+		model.enqueueChanges( () => {
+			model.batch().remove( ModelRange.createFromPositionAndShift( model.selection.getFirstPosition(), 1 ) );
+		} );
+	}
+} );

+ 7 - 0
packages/ckeditor5-engine/tests/manual/editingcontroller.md

@@ -0,0 +1,7 @@
+@bender-ui: collapsed
+
+ * Changing selection within editor should not causes any errors.
+ * Pressing enter should split the block.
+ * Pressing delete should remove character.
+
+Note: this is temporary manual test, until there is no editor manual test.

+ 76 - 6
packages/ckeditor5-engine/tests/view/document/document.js

@@ -63,24 +63,23 @@ describe( 'Document', () => {
 		it( 'should create root', () => {
 			const domP = document.createElement( 'p' );
 			const domDiv = document.createElement( 'div' );
-			domDiv.setAttribute( 'id', 'editor' );
 			domDiv.appendChild( domP );
 
 			const viewDocument = new Document();
-			const ret = viewDocument.createRoot( domDiv, 'editor' );
+			const ret = viewDocument.createRoot( domDiv );
 
 			expect( count( viewDocument.domRoots ) ).to.equal( 1 );
 			expect( count( viewDocument.viewRoots ) ).to.equal( 1 );
 
-			const domRoot = viewDocument.domRoots.get( 'editor' );
-			const viewRoot = viewDocument.viewRoots.get( 'editor' );
+			const domRoot = viewDocument.getDomRoot();
+			const viewRoot = viewDocument.getRoot();
 
 			expect( ret ).to.equal( viewRoot );
 
 			expect( domRoot ).to.equal( domDiv );
 			expect( viewDocument.domConverter.getCorrespondingDom( viewRoot ) ).to.equal( domDiv );
-			expect( viewRoot.name ).to.equal( 'div' );
-			expect( viewRoot.getAttribute( 'id' ) ).to.equal( 'editor' );
+
+			expect( viewRoot.name.toLowerCase() ).to.equal( 'div' );
 			expect( viewDocument.renderer.markedChildren.has( viewRoot ) ).to.be.true;
 		} );
 
@@ -119,6 +118,77 @@ describe( 'Document', () => {
 
 			expect( domRoot ).to.equal( domDiv );
 		} );
+
+		it( 'should create root with given name', () => {
+			const domDiv = document.createElement( 'div' );
+
+			const viewDocument = new Document();
+			const ret = viewDocument.createRoot( domDiv, 'header' );
+
+			expect( count( viewDocument.domRoots ) ).to.equal( 1 );
+			expect( count( viewDocument.viewRoots ) ).to.equal( 1 );
+
+			const domRoot = viewDocument.domRoots.get( 'header' );
+			const viewRoot = viewDocument.viewRoots.get( 'header' );
+
+			expect( ret ).to.equal( viewRoot );
+
+			expect( domRoot ).to.equal( domDiv );
+		} );
+
+		it( 'should create root without attaching DOM element', () => {
+			const viewDocument = new Document();
+			const ret = viewDocument.createRoot( 'div' );
+
+			expect( count( viewDocument.domRoots ) ).to.equal( 0 );
+			expect( count( viewDocument.viewRoots ) ).to.equal( 1 );
+			expect( ret ).to.equal( viewDocument.getRoot() );
+		} );
+	} );
+
+	describe( 'attachDomRoot', () => {
+		it( 'should create root without attach DOM element to the view element', () => {
+			const domDiv = document.createElement( 'div' );
+
+			const viewDocument = new Document();
+			const viewRoot = viewDocument.createRoot( 'div' );
+
+			expect( count( viewDocument.domRoots ) ).to.equal( 0 );
+			expect( count( viewDocument.viewRoots ) ).to.equal( 1 );
+			expect( viewRoot ).to.equal( viewDocument.getRoot() );
+
+			viewDocument.attachDomRoot( domDiv );
+
+			expect( count( viewDocument.domRoots ) ).to.equal( 1 );
+			expect( count( viewDocument.viewRoots ) ).to.equal( 1 );
+
+			expect( viewDocument.getDomRoot() ).to.equal( domDiv );
+			expect( viewDocument.domConverter.getCorrespondingDom( viewRoot ) ).to.equal( domDiv );
+
+			expect( viewDocument.renderer.markedChildren.has( viewRoot ) ).to.be.true;
+		} );
+
+		it( 'should create root without attach DOM element to the view element with given name', () => {
+			const domH1 = document.createElement( 'h1' );
+
+			const viewDocument = new Document();
+			viewDocument.createRoot( 'div' );
+			const viewH1 = viewDocument.createRoot( 'h1', 'header' );
+
+			expect( count( viewDocument.domRoots ) ).to.equal( 0 );
+			expect( count( viewDocument.viewRoots ) ).to.equal( 2 );
+			expect( viewH1 ).to.equal( viewDocument.getRoot( 'header' ) );
+
+			viewDocument.attachDomRoot( domH1, 'header' );
+
+			expect( count( viewDocument.domRoots ) ).to.equal( 1 );
+			expect( count( viewDocument.viewRoots ) ).to.equal( 2 );
+
+			expect( viewDocument.getDomRoot( 'header' ) ).to.equal( domH1 );
+			expect( viewDocument.domConverter.getCorrespondingDom( viewH1 ) ).to.equal( domH1 );
+
+			expect( viewDocument.renderer.markedChildren.has( viewH1 ) ).to.be.true;
+		} );
 	} );
 
 	describe( 'getRoot', () => {

+ 12 - 9
packages/ckeditor5-engine/tests/view/document/integration.js

@@ -9,29 +9,32 @@
 
 import Document from '/ckeditor5/engine/view/document.js';
 import ViewElement from '/ckeditor5/engine/view/element.js';
+import { isBlockFiller, BR_FILLER } from '/ckeditor5/engine/view/filler.js';
+
+import createElement from '/ckeditor5/utils/dom/createelement.js';
 
 describe( 'Document integration', () => {
 	it( 'should remove content of the DOM', () => {
-		const domP = document.createElement( 'p' );
-		const domDiv = document.createElement( 'div' );
-		domDiv.setAttribute( 'id', 'editor' );
-		domDiv.appendChild( domP );
+		const domDiv = createElement( document, 'div', { id: 'editor' }, [
+			createElement( document, 'p' ),
+			createElement( document, 'p' )
+		] );
 
 		const viewDocument = new Document();
-		viewDocument.createRoot( domDiv, 'editor' );
+		viewDocument.createRoot( domDiv );
 		viewDocument.render();
 
-		expect( domDiv.childNodes.length ).to.equal( 0 );
-		expect( domDiv.getAttribute( 'id' ) ).to.equal( 'editor' );
+		expect( domDiv.childNodes.length ).to.equal( 1 );
+		expect( isBlockFiller( domDiv.childNodes[ 0 ], BR_FILLER ) ).to.be.true;
 	} );
 
 	it( 'should render changes in the Document', () => {
 		const domDiv = document.createElement( 'div' );
 
 		const viewDocument = new Document();
-		viewDocument.createRoot( domDiv, 'editor' );
+		viewDocument.createRoot( domDiv );
 
-		viewDocument.viewRoots.get( 'editor' ).appendChildren( new ViewElement( 'p' ) );
+		viewDocument.getRoot().appendChildren( new ViewElement( 'p' ) );
 		viewDocument.render();
 
 		expect( domDiv.childNodes.length ).to.equal( 1 );