瀏覽代碼

Merge pull request #234 from ckeditor/t/features

Features base: Commands and Schema.
Piotrek Koszuliński 9 年之前
父節點
當前提交
3c595831af
共有 38 個文件被更改,包括 3041 次插入288 次删除
  1. 186 0
      packages/ckeditor5-engine/src/command/attributecommand.js
  2. 137 0
      packages/ckeditor5-engine/src/command/command.js
  3. 41 10
      packages/ckeditor5-engine/src/editor.js
  4. 22 0
      packages/ckeditor5-engine/src/feature.js
  5. 2 4
      packages/ckeditor5-engine/src/treemodel/batch.js
  6. 41 15
      packages/ckeditor5-engine/src/treemodel/delta/attributedelta.js
  7. 111 47
      packages/ckeditor5-engine/src/treemodel/document.js
  8. 2 13
      packages/ckeditor5-engine/src/treemodel/element.js
  9. 2 2
      packages/ckeditor5-engine/src/treemodel/liveposition.js
  10. 2 0
      packages/ckeditor5-engine/src/treemodel/liverange.js
  11. 28 33
      packages/ckeditor5-engine/src/treemodel/node.js
  12. 2 2
      packages/ckeditor5-engine/src/treemodel/nodelist.js
  13. 1 1
      packages/ckeditor5-engine/src/treemodel/operation/attributeoperation.js
  14. 0 3
      packages/ckeditor5-engine/src/treemodel/operation/moveoperation.js
  15. 0 6
      packages/ckeditor5-engine/src/treemodel/operation/reinsertoperation.js
  16. 0 6
      packages/ckeditor5-engine/src/treemodel/operation/removeoperation.js
  17. 125 0
      packages/ckeditor5-engine/src/treemodel/operation/rootattributeoperation.js
  18. 31 0
      packages/ckeditor5-engine/src/treemodel/operation/transform.js
  19. 4 2
      packages/ckeditor5-engine/src/treemodel/position.js
  20. 1 1
      packages/ckeditor5-engine/src/treemodel/range.js
  21. 4 3
      packages/ckeditor5-engine/src/treemodel/rootelement.js
  22. 416 0
      packages/ckeditor5-engine/src/treemodel/schema.js
  23. 341 79
      packages/ckeditor5-engine/src/treemodel/selection.js
  24. 3 3
      packages/ckeditor5-engine/src/treemodel/textfragment.js
  25. 1 1
      packages/ckeditor5-engine/src/treeview/domconverter.js
  26. 1 1
      packages/ckeditor5-engine/src/treeview/element.js
  27. 2 4
      packages/ckeditor5-engine/src/treeview/position.js
  28. 1 1
      packages/ckeditor5-engine/src/treeview/renderer.js
  29. 0 3
      packages/ckeditor5-engine/src/treeview/treeview.js
  30. 252 0
      packages/ckeditor5-engine/tests/command/attributecommand.js
  31. 149 0
      packages/ckeditor5-engine/tests/command/command.js
  32. 24 0
      packages/ckeditor5-engine/tests/editor/editor.js
  33. 42 12
      packages/ckeditor5-engine/tests/treemodel/document/document.js
  34. 200 0
      packages/ckeditor5-engine/tests/treemodel/operation/rootattributeoperation.js
  35. 230 0
      packages/ckeditor5-engine/tests/treemodel/operation/transform.js
  36. 211 0
      packages/ckeditor5-engine/tests/treemodel/schema/schema.js
  37. 172 0
      packages/ckeditor5-engine/tests/treemodel/schema/schemaitem.js
  38. 254 36
      packages/ckeditor5-engine/tests/treemodel/selection.js

+ 186 - 0
packages/ckeditor5-engine/src/command/attributecommand.js

@@ -0,0 +1,186 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Command from './command.js';
+import TreeWalker from '../treemodel/treewalker.js';
+import Range from '../treemodel/range.js';
+
+/**
+ * An extension of basic {@link core.command.Command} class, which provides utilities for a command that sets a single
+ * attribute on a text or element with value `true`. AttributeCommand uses {@link core.treeModel.Document#selection} to
+ * decide which nodes (if any) should be changed, and applies or removes attributes from them.
+ * See {@link core.treeView.Converter#execute} for more.
+ *
+ * The command checks {@link core.treeModel.Document#schema} to decide if it should be enabled.
+ * See {@link core.treeView.Converter#checkSchema} for more.
+ *
+ * @memberOf core.command
+ */
+export default class AttributeCommand extends Command {
+	/**
+	 * @see core.command.Command
+	 * @param {core.Editor} editor
+	 * @param {String} attributeKey Attribute that will be set by the command.
+	 */
+	constructor( editor, attributeKey ) {
+		super( editor );
+
+		/**
+		 * Attribute that will be set by the command.
+		 *
+		 * @member {String} core.command.AttributeCommand#attributeKey
+		 */
+		this.attributeKey = attributeKey;
+
+		/**
+		 * Flag indicating whether command is active. For collapsed selection it means that typed characters will have
+		 * the command's attribute set. For range selection it means that all nodes inside have the attribute applied.
+		 *
+		 * @member {Boolean} core.command.AttributeCommand#value
+		 */
+		this.set( 'value', false );
+
+		this.listenTo( this.editor.document.selection, 'change:attribute', () => {
+			this.value = this.editor.document.selection.hasAttribute( this.attributeKey );
+		} );
+	}
+
+	/**
+	 * Checks {@link core.treeModel.Document#schema} to decide if the command should be enabled:
+	 * * if selection is on range, the command is enabled if any of nodes in that range can have bold,
+	 * * if selection is collapsed, the command is enabled if text with bold is allowed in that node.
+	 *
+	 * @private
+	 * @returns {Boolean}
+	 */
+	_checkEnabled() {
+		const selection = this.editor.document.selection;
+		const schema = this.editor.document.schema;
+
+		if ( selection.isCollapsed ) {
+			// Check whether schema allows for a test with `attributeKey` in caret position.
+			return schema.checkAtPosition( selection.getFirstPosition(), '$text', this.attributeKey );
+		} else {
+			const ranges = selection.getRanges();
+
+			// For all ranges, check nodes in them until you find a node that is allowed to have `attributeKey` attribute.
+			for ( let range of ranges ) {
+				const walker = new TreeWalker( { boundaries: range, mergeCharacters: true } );
+				let last = walker.position;
+				let step = walker.next();
+
+				// Walk the range.
+				while ( !step.done ) {
+					// If returned item does not have name property, it is a treeModel.TextFragment.
+					const name = step.value.item.name || '$text';
+
+					if ( schema.checkAtPosition( last, name, this.attributeKey ) ) {
+						// If we found a node that is allowed to have the attribute, return true.
+						return true;
+					}
+
+					last = walker.position;
+					step = walker.next();
+				}
+			}
+		}
+
+		// If we haven't found such node, return false.
+		return false;
+	}
+
+	/**
+	 * Executes the command: adds or removes attributes to nodes or selection.
+	 *
+	 * If the command is active (`value == true`), it will remove attributes. Otherwise, it will set attributes.
+	 *
+	 * The execution result differs, depending on the {@link core.treeModel.Document#selection}:
+	 * * if selection is on a range, the command applies the attribute on all nodes in that ranges
+	 * (if they are allowed to have this attribute by the{@link core.treeModel.Schema schema}),
+	 * * if selection is collapsed in non-empty node, the command applies attribute to the {@link core.treeModel.Document#selection}
+	 * itself (note that typed characters copy attributes from selection),
+	 * * if selection is collapsed in empty node, the command applies attribute to the parent node of selection (note
+	 * that selection inherits all attributes from a node if it is in empty node).
+	 *
+	 * If the command is disabled (`isEnabled == false`) when it is executed, nothing will happen.
+	 *
+	 * @private
+	 * @param {Boolean} [forceValue] If set it will force command behavior. If `true`, command will apply attribute,
+	 * otherwise command will remove attribute. If not set, command will look for it's current value to decide what it should do.
+	 */
+	_doExecute( forceValue ) {
+		const document = this.editor.document;
+		const selection = document.selection;
+		const value = ( forceValue === undefined ) ? !this.value : forceValue;
+
+		if ( selection.isCollapsed ) {
+			if ( value ) {
+				selection.setAttribute( this.attributeKey, true );
+			} else {
+				selection.removeAttribute( this.attributeKey );
+			}
+		} else {
+			// If selection has non-collapsed ranges, we change attribute on nodes inside those ranges.
+			document.enqueueChanges( () => {
+				const ranges = this._getSchemaValidRanges( selection.getRanges() );
+
+				// Keep it as one undo step.
+				const batch = document.batch();
+
+				for ( let range of ranges ) {
+					if ( value ) {
+						batch.setAttr( this.attributeKey, value, range );
+					} else {
+						batch.removeAttr( this.attributeKey, range );
+					}
+				}
+			} );
+		}
+	}
+
+	/**
+	 * Walks through given array of ranges and removes parts of them that are not allowed by schema to have the
+	 * attribute set. This is done by breaking a range in two and omitting the not allowed part.
+	 *
+	 * @private
+	 * @param {Array.<core.treeModel.Range>} ranges Ranges to be validated.
+	 * @returns {Array.<core.treeModel.Range>} Ranges without invalid parts.
+	 */
+	_getSchemaValidRanges( ranges ) {
+		const validRanges = [];
+
+		for ( let range of ranges ) {
+			const walker = new TreeWalker( { boundaries: range, mergeCharacters: true } );
+			let step = walker.next();
+
+			let last = range.start;
+			let from = range.start;
+			let to = range.end;
+
+			while ( !step.done ) {
+				const name = step.value.item.name || '$text';
+
+				if ( !this.editor.document.schema.checkAtPosition( last, name, this.attributeKey ) ) {
+					if ( !from.isEqual( last ) ) {
+						validRanges.push( new Range( from, last ) );
+					}
+
+					from = walker.position;
+				}
+
+				last = walker.position;
+				step = walker.next();
+			}
+
+			if ( from && !from.isEqual( to ) ) {
+				validRanges.push( new Range( from, to ) );
+			}
+		}
+
+		return validRanges;
+	}
+}

+ 137 - 0
packages/ckeditor5-engine/src/command/command.js

@@ -0,0 +1,137 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import ObservableMixin from '../../utils/observablemixin.js';
+import utils from '../../utils/utils.js';
+
+/**
+ * The base class for CKEditor commands.
+ *
+ * Commands are main way to manipulate editor contents and state. They are mostly used by UI elements (or by other
+ * commands) to make changes in Tree Model. Commands are available in every part of code that has access to
+ * {@link core.Editor} instance, since they are registered in it and executed through {@link core.Editor#execute}.
+ * Commands instances are available through {@link core.Editor#commands}.
+ *
+ * This is an abstract base class for all commands.
+ *
+ * @memberOf core.command
+ * @mixes utils.ObservableMixin
+ */
+export default class Command {
+	/**
+	 * Creates a new Command instance.
+	 *
+	 * @param {core.Editor} editor Editor on which this command will be used.
+	 */
+	constructor( editor ) {
+		/**
+		 * Editor on which this command will be used.
+		 *
+		 * @member {core.Editor} core.command.Command#editor
+		 */
+		this.editor = editor;
+
+		/**
+		 * Flag indicating whether a command is enabled or disabled.
+		 * A disabled command should do nothing upon it's execution.
+		 *
+		 * @member {Boolean} core.command.Command#isEnabled
+		 */
+		this.set( 'isEnabled', true );
+
+		// If schema checking function is specified, add it to the `refreshState` listeners.
+		// Feature will be disabled if it does not apply to schema requirements.
+		if ( this._checkEnabled ) {
+			this.on( 'refreshState', ( evt, data ) => {
+				data.isEnabled = this._checkEnabled();
+			} );
+		}
+	}
+
+	/**
+	 * Checks if a command should be enabled according to its own rules. Mostly it will check schema to see if the command
+	 * is allowed to be executed in given position. This method can be defined in child class (but is not obligatory).
+	 * If it is defined, it will be added as a callback to `refreshState` event.
+	 *
+	 * @protected
+	 * @method core.command.Command#_checkEnabled
+	 * @returns {Boolean} `true` if command should be enabled according to {@link core.treeModel.Document#schema}. `false` otherwise.
+	 */
+
+	/**
+	 * Fires `refreshState` event and checks it's resolve value to decide whether command should be enabled or not.
+	 * Other parts of code might listen to `refreshState` event on this command and add their callbacks. This
+	 * way the responsibility of deciding whether a command should be enabled is shared.
+	 *
+	 * @fires {@link core.command.Command#refreshState refreshState}
+	 */
+	refreshState() {
+		const data = { isEnabled: true };
+		this.fire( 'refreshState', data );
+
+		this.isEnabled = data.isEnabled;
+	}
+
+	/**
+	 * Executes the command if it is enabled.
+	 *
+	 * @protected
+	 * @param {*} param Parameter passed to {@link core.command.Command#execute execute} method of this command.
+	 */
+	_execute( param ) {
+		if ( this.isEnabled ) {
+			this._doExecute( param );
+		}
+	}
+
+	/**
+	 * Disables the command. This should be used only by the command itself. Other parts of code should add
+	 * listeners to `refreshState` event.
+	 *
+	 * @protected
+	 */
+	_disable() {
+		this.on( 'refreshState', disableCallback );
+		this.refreshState();
+	}
+
+	/**
+	 * Enables the command (internally). This should be used only by the command itself. Command will be enabled if
+	 * other listeners does not return false on `refreshState` event callbacks. Firing {@link core.command.Command#_enable}
+	 * does not guarantee that {@link core.command.Command#isEnabled} will be set to true, as it depends on other listeners.
+	 *
+	 * @protected
+	 */
+	_enable() {
+		this.off( 'refreshState', disableCallback );
+		this.refreshState();
+	}
+
+	/**
+	 * Executes command.
+	 * This is an abstract method that should be overwritten in child classes.
+	 *
+	 * @protected
+	 */
+	_doExecute() {}
+}
+
+function disableCallback( evt, data ) {
+	data.isEnabled = false;
+}
+
+utils.mix( Command, ObservableMixin );
+
+/**
+ * Fired whenever command has to have its {@link core.command.Command#isEnabled} property refreshed. Every feature,
+ * command or other class which needs to disable command (set `isEnabled` to `false`) should listen to this
+ * event.
+ *
+ * @event core.command.Command#refreshState
+ * @param {Object} data
+ * @param {Boolean} [data.isEnabled=true]
+ */

+ 41 - 10
packages/ckeditor5-engine/src/editor.js

@@ -8,6 +8,7 @@
 import ObservableMixin from '../utils/observablemixin.js';
 import EditorConfig from './editorconfig.js';
 import PluginCollection from './plugincollection.js';
+import Document from './treemodel/document.js';
 import CKEditorError from '../utils/ckeditorerror.js';
 import Locale from '../utils/locale.js';
 import isArray from '../utils/lib/lodash/isArray.js';
@@ -42,7 +43,7 @@ export default class Editor {
 		/**
 		 * Holds all configurations specific to this editor instance.
 		 *
-		 * This instance of the {@link Config} class is customized so its {@link Config#get} method will retrieve
+		 * This instance of the {@link utils.Config} class is customized so its {@link utils.Config#get} method will retrieve
 		 * global configurations available in {@link CKEDITOR.config} if configurations are not found in the
 		 * instance itself.
 		 *
@@ -59,6 +60,22 @@ export default class Editor {
 		 */
 		this.plugins = new PluginCollection( this );
 
+		/**
+		 * Tree Model document managed by this editor.
+		 *
+		 * @readonly
+		 * @member {core.treeModel.Document} core.Editor#document
+		 */
+		this.document = new Document();
+
+		/**
+		 * Commands registered to the editor.
+		 *
+		 * @readonly
+		 * @member {Map} core.Editor#commands
+		 */
+		this.commands = new Map();
+
 		/**
 		 * @readonly
 		 * @member {utils.Locale} core.Editor#locale
@@ -172,6 +189,27 @@ export default class Editor {
 	getData() {
 		return this.editable.getData();
 	}
+
+	/**
+	 * Executes specified command with given parameter.
+	 *
+	 * @param {String} commandName Name of command to execute.
+	 * @param {*} [commandParam] If set, command will be executed with this parameter.
+	 */
+	execute( commandName, commandParam ) {
+		let command = this.commands.get( commandName );
+
+		if ( !command ) {
+			/**
+			 * Specified command has not been added to the editor.
+			 *
+			 * @error editor-command-not-found
+			 */
+			throw new CKEditorError( 'editor-command-not-found: Specified command has not been added to the editor.' );
+		}
+
+		command._execute( commandParam );
+	}
 }
 
 utils.mix( Editor, ObservableMixin );
@@ -180,13 +218,6 @@ utils.mix( Editor, ObservableMixin );
  * Fired when this editor instance is destroyed. The editor at this point is not usable and this event should be used to
  * perform the clean-up in any plugin.
  *
- * @event core.Editor#destroy
- */
-
-/**
- * @cfg {String[]} features
- */
-
-/**
- * @cfg {String} creator
+ * @memberOf core.Editor
+ * @event destroy
  */

+ 22 - 0
packages/ckeditor5-engine/src/feature.js

@@ -0,0 +1,22 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Plugin from './plugin.js';
+
+/**
+ * The base class for CKEditor feature classes. Features are main way to enhance CKEditor abilities with tools,
+ * utilities, services and components.
+ *
+ * The main responsibilities for Feature are:
+ * * setting required dependencies (see {@link core.Plugin#requires},
+ * * configuring, instantiating and registering commands to editor,
+ * * registering converters to editor (if the feature operates on Tree Model),
+ * * setting and registering UI components (if the feature uses it).
+ *
+ * @memberOf core
+ */
+export default class Feature extends Plugin {}

+ 2 - 4
packages/ckeditor5-engine/src/treemodel/batch.js

@@ -40,18 +40,16 @@ export default class Batch {
 		/**
 		 * Document which this Batch changes.
 		 *
-		 * @member core.treeModel.Batch#doc
+		 * @member {core.treeModel.Document} core.treeModel.Batch#doc
 		 * @readonly
-		 * @type {core.treeModel.Document}
 		 */
 		this.doc = doc;
 
 		/**
 		 * Array of deltas which compose Batch.
 		 *
-		 * @member core.treeModel.Batch#deltas
+		 * @member {Array.<core.treeModel.delta.Delta>} core.treeModel.Batch#deltas
 		 * @readonly
-		 * @type {Array.<core.treeModel.delta.Delta>}
 		 */
 		this.deltas = [];
 	}

+ 41 - 15
packages/ckeditor5-engine/src/treemodel/delta/attributedelta.js

@@ -8,8 +8,10 @@
 import Delta from './delta.js';
 import { register } from '../batch.js';
 import AttributeOperation from '../operation/attributeoperation.js';
+import RootAttributeOperation from '../operation/rootattributeoperation.js';
 import Position from '../position.js';
 import Range from '../range.js';
+import RootElement from '../rootelement.js';
 import Element from '../element.js';
 
 /**
@@ -77,6 +79,16 @@ export default class AttributeDelta extends Delta {
 	}
 }
 
+/**
+ * To provide specific OT behavior and better collisions solving, change methods ({@link core.treeModel.Batch#setAttr}
+ * and {@link core.treeModel.Batch#removeAttr}) use `RootAttributeDelta` class which inherits from the `Delta` class and may
+ * overwrite some methods.
+ *
+ * @memberOf core.treeModel.delta
+ * @extends core.treeModel.delta.Delta
+ */
+export class RootAttributeDelta extends Delta {}
+
 /**
  * Sets the value of the attribute of the node or on the range.
  *
@@ -107,42 +119,54 @@ register( 'removeAttr', function( key, nodeOrRange ) {
 } );
 
 function attribute( batch, key, value, nodeOrRange ) {
-	const delta = new AttributeDelta();
+	let delta;
 
 	if ( nodeOrRange instanceof Range ) {
-		changeRange( batch.doc, delta, key, value, nodeOrRange );
+		delta = changeRange( batch.doc, key, value, nodeOrRange );
 	} else {
-		changeNode( batch.doc, delta, key, value, nodeOrRange );
+		delta = changeNode( batch.doc, key, value, nodeOrRange );
 	}
 
 	batch.addDelta( delta );
 }
 
-function changeNode( doc, delta, key, value, node ) {
+function changeNode( doc, key, value, node ) {
 	const previousValue = node.getAttribute( key );
-	let range;
+	let range, operation;
+
+	const delta = node instanceof RootElement ? new RootAttributeDelta() : new AttributeDelta();
 
 	if ( previousValue != value ) {
-		if ( node instanceof Element ) {
-			// If we change the attribute of the element, we do not want to change attributes of its children, so
-			// the end on the range can not be put after the closing tag, it should be inside that element with the
-			// offset 0, so the range will contains only the opening tag...
-			range = new Range( Position.createBefore( node ), Position.createFromParentAndOffset( node, 0 ) );
+		if ( node instanceof RootElement ) {
+			// If we change attributes of root element, we have to use `RootAttributeOperation`.
+			operation = new RootAttributeOperation( node, key, previousValue, value, doc.version );
 		} else {
-			// ...but for characters we can not put the range inside it, so we end the range after that character.
-			range = new Range( Position.createBefore( node ), Position.createAfter( node ) );
-		}
+			if ( node instanceof Element ) {
+				// If we change the attribute of the element, we do not want to change attributes of its children, so
+				// the end on the range can not be put after the closing tag, it should be inside that element with the
+				// offset 0, so the range will contains only the opening tag...
+				range = new Range( Position.createBefore( node ), Position.createFromParentAndOffset( node, 0 ) );
+			} else {
+				// ...but for characters we can not put the range inside it, so we end the range after that character.
+				range = new Range( Position.createBefore( node ), Position.createAfter( node ) );
+			}
 
-		const operation = new AttributeOperation( range, key, previousValue, value, doc.version );
+			operation = new AttributeOperation( range, key, previousValue, value, doc.version );
+		}
 
 		doc.applyOperation( operation );
 		delta.addOperation( operation );
 	}
+
+	// It is expected that this method returns a delta.
+	return delta;
 }
 
 // Because attribute operation needs to have the same attribute value on the whole range, this function split the range
 // into smaller parts.
-function changeRange( doc, delta, attributeKey, attributeValue, range ) {
+function changeRange( doc, attributeKey, attributeValue, range ) {
+	const delta = new AttributeDelta();
+
 	// Position of the last split, the beginning of the new range.
 	let lastSplitPosition = range.start;
 
@@ -185,4 +209,6 @@ function changeRange( doc, delta, attributeKey, attributeValue, range ) {
 		doc.applyOperation( operation );
 		delta.addOperation( operation );
 	}
+
+	return delta;
 }

+ 111 - 47
packages/ckeditor5-engine/src/treemodel/document.js

@@ -6,9 +6,8 @@
 'use strict';
 
 // Load all basic deltas and transformations, they register themselves, but they need to be imported somewhere.
-import deltas from './delta/basic-deltas.js';
-import transformations from './delta/basic-transformations.js';
-/*jshint unused: false*/
+import deltas from './delta/basic-deltas.js'; // jshint ignore:line
+import transformations from './delta/basic-transformations.js'; // jshint ignore:line
 
 import RootElement from './rootelement.js';
 import Batch from './batch.js';
@@ -16,6 +15,8 @@ import Selection from './selection.js';
 import EmitterMixin from '../../utils/emittermixin.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
 import utils from '../../utils/utils.js';
+import Schema from './schema.js';
+import clone from '../../utils/lib/lodash/clone.js';
 
 const graveyardSymbol = Symbol( 'graveyard' );
 
@@ -38,14 +39,6 @@ export default class Document {
 	 * Creates an empty document instance with no {@link core.treeModel.Document#roots} (other than graveyard).
 	 */
 	constructor() {
-		/**
-		 * List of roots that are owned and managed by this document.
-		 *
-		 * @readonly
-		 * @member {Map} core.treeModel.Document#roots
-		 */
-		this.roots = new Map();
-
 		/**
 		 * Document version. It starts from `0` and every operation increases the version number. It is used to ensure that
 		 * operations are applied on the proper document version. If the {@link core.treeModel.operation.Operation#baseVersion} will
@@ -56,8 +49,20 @@ export default class Document {
 		 */
 		this.version = 0;
 
-		// Graveyard tree root. Document always have a graveyard root, which stores removed nodes.
-		this.createRoot( graveyardSymbol );
+		/**
+		 * Selection done on this document.
+		 *
+		 * @readonly
+		 * @member {core.treeModel.Selection} core.treeModel.Document#selection
+		 */
+		this.selection = new Selection( this );
+
+		/**
+		 * Schema for this document.
+		 *
+		 * @member {core.treeModel.Schema} core.treeModel.Document#schema
+		 */
+		this.schema = new Schema();
 
 		/**
 		 * Array of pending changes. See: {@link core.treeModel.Document#enqueueChanges}.
@@ -68,12 +73,26 @@ export default class Document {
 		this._pendingChanges = [];
 
 		/**
-		 * Selection done on this document.
+		 * List of roots that are owned and managed by this document. Use {@link core.treeModel.document#createRoot} and
+		 * {@link core.treeModel.document#getRoot} to manipulate it.
 		 *
 		 * @readonly
-		 * @member {core.treeModel.Selection} core.treeModel.Document#selection
+		 * @protected
+		 * @member {Map} core.treeModel.Document#roots
 		 */
-		this.selection = new Selection();
+		this._roots = new Map();
+
+		// Add events that will update selection attributes.
+		this.selection.on( 'change:range', () => {
+			this.selection._updateAttributes();
+		} );
+
+		this.on( 'changesDone', () => {
+			this.selection._updateAttributes();
+		} );
+
+		// Graveyard tree root. Document always have a graveyard root, which stores removed nodes.
+		this.createRoot( graveyardSymbol );
 	}
 
 	/**
@@ -128,38 +147,48 @@ export default class Document {
 	/**
 	 * Creates a new top-level root.
 	 *
-	 * @param {String|Symbol} name Unique root name.
+	 * @param {String|Symbol} id Unique root id.
+	 * @param {String} name Element name.
 	 * @returns {core.treeModel.RootElement} Created root.
 	 */
-	createRoot( name ) {
-		if ( this.roots.has( name ) ) {
+	createRoot( id, name ) {
+		if ( this._roots.has( id ) ) {
 			/**
-			 * Root with specified name already exists.
+			 * Root with specified id already exists.
 			 *
-			 * @error document-createRoot-name-exists
+			 * @error document-createRoot-id-exists
 			 * @param {core.treeModel.Document} doc
-			 * @param {String} name
+			 * @param {String} id
 			 */
 			throw new CKEditorError(
-				'document-createRoot-name-exists: Root with specified name already exists.',
-				{ name: name }
+				'document-createRoot-id-exists: Root with specified id already exists.',
+				{ id: id }
 			);
 		}
 
-		const root = new RootElement( this );
-		this.roots.set( name, root );
+		const root = new RootElement( this, name );
+		this._roots.set( id, root );
 
 		return root;
 	}
 
 	/**
-	 * Enqueue a callback with document changes. Any changes to be done on document (mostly using {@link core.treeModel.Document#batch} should
-	 * be placed in the queued callback. If no other plugin is changing document at the moment, the callback will be
+	 * Removes all events listeners set by document instance.
+	 */
+	destroy() {
+		this.selection.destroy();
+		this.stopListening();
+	}
+
+	/**
+	 * Enqueues document changes. Any changes to be done on document (mostly using {@link core.treeModel.Document#batch}
+	 * should be placed in the queued callback. If no other plugin is changing document at the moment, the callback will be
 	 * called immediately. Otherwise it will wait for all previously queued changes to finish happening. This way
 	 * queued callback will not interrupt other callbacks.
 	 *
 	 * When all queued changes are done {@link core.treeModel.Document#changesDone} event is fired.
 	 *
+	 * @fires {@link core.treeModel.Document#changesDone}
 	 * @param {Function} callback Callback to enqueue.
 	 */
 	enqueueChanges( callback ) {
@@ -176,26 +205,58 @@ export default class Document {
 	}
 
 	/**
-	 * Returns top-level root by it's name.
+	 * Returns top-level root by it's id.
 	 *
-	 * @param {String|Symbol} name Name of the root to get.
-	 * @returns {core.treeModel.RootElement} Root registered under given name.
+	 * @param {String|Symbol} id Unique root id.
+	 * @returns {core.treeModel.RootElement} Root registered under given id.
 	 */
-	getRoot( name ) {
-		if ( !this.roots.has( name ) ) {
+	getRoot( id ) {
+		if ( !this._roots.has( id ) ) {
 			/**
-			 * Root with specified name does not exist.
+			 * Root with specified id does not exist.
 			 *
-			 * @error document-createRoot-root-not-exist
-			 * @param {String} name
+			 * @error document-getRoot-root-not-exist
+			 * @param {String} id
 			 */
 			throw new CKEditorError(
-				'document-createRoot-root-not-exist: Root with specified name does not exist.',
-				{ name: name }
+				'document-getRoot-root-not-exist: Root with specified id does not exist.',
+				{ id: id }
 			);
 		}
 
-		return this.roots.get( name );
+		return this._roots.get( id );
+	}
+
+	/**
+	 * Custom toJSON method to solve child-parent circular dependencies.
+	 *
+	 * @returns {Object} Clone of this object with the document property changed to string.
+	 */
+	toJSON() {
+		const json = clone( this );
+
+		// Due to circular references we need to remove parent reference.
+		json.selection = '[core.treeModel.Selection]';
+
+		return {};
+	}
+
+	/**
+	 * Returns default root for this document which is either the first root that was added to the the document using
+	 * {@link core.treeModel.Document#createRoot} or the {@link core.treeModel.Document#graveyard graveyard root} if
+	 * no other roots were created.
+	 *
+	 * @protected
+	 * @returns {core.treeModel.RootElement} The default root for this document.
+	 */
+	_getDefaultRoot() {
+		for ( let root of this._roots.values() ) {
+			if ( root !== this.graveyard ) {
+				return root;
+			}
+		}
+
+		return this.graveyard;
 	}
 
 	/**
@@ -203,11 +264,12 @@ export default class Document {
 	 *
 	 * There are 5 types of change:
 	 *
-	 * * `'insert'` when nodes are inserted,
-	 * * `'remove'` when nodes are removed,
-	 * * `'reinsert'` when remove is undone,
-	 * * `'move'` when nodes are moved,
-	 * * `'attribute'` when attributes change. TODO attribute
+	 * * 'insert' when nodes are inserted,
+	 * * 'remove' when nodes are removed,
+	 * * 'reinsert' when remove is undone,
+	 * * 'move' when nodes are moved,
+	 * * 'attribute' when attributes change,
+	 * * 'rootattribute' when attributes for root element change.
 	 *
 	 * Change event is fired after the change is done. This means that any ranges or positions passed in
 	 * `changeInfo` are referencing nodes and paths in updated tree model.
@@ -215,8 +277,10 @@ export default class Document {
 	 * @event core.treeModel.Document#change
 	 * @param {String} type Change type, possible option: `'insert'`, `'remove'`, `'reinsert'`, `'move'`, `'attribute'`.
 	 * @param {Object} changeInfo Additional information about the change.
-	 * @param {core.treeModel.Range} changeInfo.range Range containing changed nodes. Note that for `'remove'` the range will be in the
-	 * {@link core.treeModel.Document#graveyard graveyard root}.
+	 * @param {core.treeModel.Range} [changeInfo.range] Range containing changed nodes. Note that for `'remove'` the range will be in the
+	 * {@link core.treeModel.Document#graveyard graveyard root}. This is undefined for `'rootattribute'` type.
+	 * @param {core.treeModel.RootElement} [changeInfo.root] Root element which attributes got changed. This is defined
+	 * only for `'rootattribute'` type.
 	 * @param {core.treeModel.Position} [changeInfo.sourcePosition] Change source position. Exists for `'remove'`, `'reinsert'` and `'move'`.
 	 * Note that for 'reinsert' the source position will be in the {@link core.treeModel.Document#graveyard graveyard root}.
 	 * @param {String} [changeInfo.key] Only for `'attribute'` type. Key of changed / inserted / removed attribute.
@@ -224,7 +288,7 @@ export default class Document {
 	 * is `undefined` it means that new attribute was inserted. Otherwise it contains changed or removed attribute value.
 	 * @param {*} [changeInfo.newValue] Only for `'attribute'` type. If the type is `'attribute'` and `newValue`
 	 * is `undefined` it means that attribute was removed. Otherwise it contains changed or inserted attribute value.
-	 * @param {core.treeModel.Batch} batch A {@link core.treeModel.Batch batch} of changes which this change is a part of.
+	 * @param {core.treeModel.Batch} batch A batch of changes which this change is a part of.
 	 */
 
 	/**

+ 2 - 13
packages/ckeditor5-engine/src/treemodel/element.js

@@ -30,7 +30,7 @@ export default class Element extends Node {
 		 * Element name.
 		 *
 		 * @readonly
-		 * @type {String}
+		 * @member {String} core.treeModel.Element#name
 		 */
 		this.name = name;
 
@@ -38,7 +38,7 @@ export default class Element extends Node {
 		 * List of children nodes.
 		 *
 		 * @protected
-		 * @type {core.treeModel.NodeList}
+		 * @member {core.treeModel.NodeList} core.treeModel.Element#_children
 		 */
 		this._children = new NodeList();
 
@@ -50,7 +50,6 @@ export default class Element extends Node {
 	/**
 	 * Gets child at the given index.
 	 *
-	 * @method core.treeModel.Element#getChild
 	 * @param {Number} index Index of child.
 	 * @returns {core.treeModel.Node} Child node.
 	 */
@@ -61,7 +60,6 @@ export default class Element extends Node {
 	/**
 	 * Gets the number of element's children.
 	 *
-	 * @method core.treeModel.Element#getChildCount
 	 * @returns {Number} The number of element's children.
 	 */
 	getChildCount() {
@@ -71,7 +69,6 @@ export default class Element extends Node {
 	/**
 	 * Gets index of the given child node.
 	 *
-	 * @method core.treeModel.Element#getChildIndex
 	 * @param {core.treeModel.Node} node Child node.
 	 * @returns {Number} Index of the child node.
 	 */
@@ -86,7 +83,6 @@ export default class Element extends Node {
 	 * Note that the list of children can be modified only in elements not yet attached to the document.
 	 * All attached nodes should be modified using the {@link core.treeModel.operation.InsertOperation}.
 	 *
-	 * @method core.treeModel.Element#appendChildren
 	 * @param {core.treeModel.NodeSet} nodes The list of nodes to be inserted.
 	 */
 	appendChildren( nodes ) {
@@ -99,7 +95,6 @@ export default class Element extends Node {
 	 * Note that the list of children can be modified only in elements not yet attached to the document.
 	 * All attached nodes should be modified using the {@link core.treeModel.operation.InsertOperation}.
 	 *
-	 * @method core.treeModel.Element#insertChildren
 	 * @param {Number} index Position where nodes should be inserted.
 	 * @param {core.treeModel.NodeSet} nodes The list of nodes to be inserted.
 	 * The list of nodes can be of any type accepted by the {@link core.treeModel.NodeList} constructor.
@@ -120,7 +115,6 @@ export default class Element extends Node {
 	 * Note that the list of children can be modified only in elements not yet attached to the document.
 	 * All attached nodes should be modified using the {@link core.treeModel.operation.RemoveOperation}.
 	 *
-	 * @method core.treeModel.Element#removeChildren
 	 * @param {Number} index Position of the first node to remove.
 	 * @param {Number} [number] Number of nodes to remove.
 	 * @returns {core.treeModel.NodeList} The list of removed nodes.
@@ -142,7 +136,6 @@ export default class Element extends Node {
 	/**
 	 * Sets attribute on the element. If attribute with the same key already is set, it overwrites its values.
 	 *
-	 * @method core.treeModel.Element#setAttribute
 	 * @param {String} key Key of attribute to set.
 	 * @param {*} value Attribute value.
 	 */
@@ -153,7 +146,6 @@ export default class Element extends Node {
 	/**
 	 * Removes all attributes from the element and sets given attributes.
 	 *
-	 * @method core.treeModel.Element#setAttributesTo
 	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set. See {@link core.treeModel.Node#getAttributes}.
 	 */
 	setAttributesTo( attrs ) {
@@ -163,7 +155,6 @@ export default class Element extends Node {
 	/**
 	 * Removes an attribute with given key from the element.
 	 *
-	 * @method core.treeModel.Element#removeAttribute
 	 * @param {String} key Key of attribute to remove.
 	 * @returns {Boolean} `true` if the attribute was set on the element, `false` otherwise.
 	 */
@@ -173,8 +164,6 @@ export default class Element extends Node {
 
 	/**
 	 * Removes all attributes from the element.
-	 *
-	 * @method core.treeModel.Element#clearAttributes
 	 */
 	clearAttributes() {
 		this._attrs.clear();

+ 2 - 2
packages/ckeditor5-engine/src/treemodel/liveposition.js

@@ -59,8 +59,6 @@ export default class LivePosition extends Position {
 	 * Unbinds all events previously bound by LivePosition. Use it whenever you don't need LivePosition instance
 	 * anymore (i.e. when leaving scope in which it was declared or before re-assigning variable that was
 	 * referring to it).
-	 *
-	 * @method core.treeModel.LivePosition#detach
 	 */
 	detach() {
 		this.stopListening();
@@ -159,6 +157,8 @@ function transform( type, range, position ) {
 				transformed = this.getTransformedByMove( position, range.start, howMany, insertBefore );
 			}
 			break;
+		default:
+			return;
 	}
 
 	this.path = transformed.path;

+ 2 - 0
packages/ckeditor5-engine/src/treemodel/liverange.js

@@ -12,7 +12,9 @@ import utils from '../../utils/utils.js';
 
 /**
  * LiveRange is a Range in the Tree Model that updates itself as the tree changes. It may be used as a bookmark.
+ *
  * **Note:** Constructor creates it's own {@link core.treeModel.LivePosition} instances basing on passed values.
+ *
  * **Note:** Be very careful when dealing with LiveRange. Each LiveRange instance bind events that might
  * have to be unbound. Use {@link core.treeModel.LiveRange#detach detach} whenever you don't need LiveRange anymore.
  *

+ 28 - 33
packages/ckeditor5-engine/src/treemodel/node.js

@@ -10,22 +10,23 @@ import utils from '../../utils/utils.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
 
 /**
- * Creates a tree node.
+ * Tree model node. This is an abstract class for other classes representing different nodes in Tree Model.
  *
- * This is an abstract class, so this constructor should not be used directly.
- *
- * @param {Iterable|Object} attrs Iterable collection of attributes.
- *
- * @abstract
- * @class core.treeModel.Node
- * @classdesc Abstract document tree node class.
+ * @memberOf core.treeModel
  */
 export default class Node {
+	/**
+	 * Creates a tree node.
+	 *
+	 * This is an abstract class, so this constructor should not be used directly.
+	 *
+	 * @param {Iterable|Object} attrs Iterable collection of attributes.
+	 * @abstract
+	 */
 	constructor( attrs ) {
 		/**
 		 * Parent element. Null by default. Set by {@link core.treeModel.Element#insertChildren}.
 		 *
-		 * @member core.treeModel.Node#parent
 		 * @readonly
 		 * @member {core.treeModel.Element|null} core.treeModel.Node#parent
 		 */
@@ -48,7 +49,7 @@ export default class Node {
 	 * Depth of the node, which equals to total number of its parents.
 	 *
 	 * @readonly
-	 * @member {Number} core.treeModel.Node#depth
+	 * @type {Number}
 	 */
 	get depth() {
 		let depth = 0;
@@ -67,7 +68,7 @@ export default class Node {
 	 * Nodes next sibling or `null` if it is the last child.
 	 *
 	 * @readonly
-	 * @member {core.treeModel.Node|null} core.treeModel.Node#nextSibling
+	 * @type {core.treeModel.Node|null}
 	 */
 	get nextSibling() {
 		const index = this.getIndex();
@@ -79,7 +80,7 @@ export default class Node {
 	 * Nodes previous sibling or null if it is the last child.
 	 *
 	 * @readonly
-	 * @member {core.treeModel.Node|null} core.treeModel.Node#previousSibling
+	 * @type {core.treeModel.Node|null}
 	 */
 	get previousSibling() {
 		const index = this.getIndex();
@@ -91,7 +92,7 @@ export default class Node {
 	 * The top parent for the node. If node has no parent it is the root itself.
 	 *
 	 * @readonly
-	 * @member {Number} core.treeModel.Node#root
+	 * @type {Number}
 	 */
 	get root() {
 		let root = this;
@@ -108,7 +109,6 @@ export default class Node {
 	 *
 	 * Throws error if the parent element does not contain this node.
 	 *
-	 * @method core.treeModel.Node#getIndes
 	 * @returns {Number|Null} Index of the node in the parent element or null if the node has not parent.
 	 */
 	getIndex() {
@@ -134,7 +134,6 @@ export default class Node {
 	 * Gets path to the node. For example if the node is the second child of the first child of the root then the path
 	 * will be `[ 1, 2 ]`. This path can be used as a parameter of {@link core.treeModel.Position}.
 	 *
-	 * @method core.treeModel.Node#getPath
 	 * @returns {Number[]} The path.
 	 */
 	getPath() {
@@ -149,25 +148,9 @@ export default class Node {
 		return path;
 	}
 
-	/**
-	 * Custom toJSON method to solve child-parent circular dependencies.
-	 *
-	 * @method core.treeModel.Node#toJSON
-	 * @returns {Object} Clone of this object with the parent property replaced with its name.
-	 */
-	toJSON() {
-		const json = clone( this );
-
-		// Due to circular references we need to remove parent reference.
-		json.parent = this.parent ? this.parent.name : null;
-
-		return json;
-	}
-
 	/**
 	 * Checks if the node has an attribute for given key.
 	 *
-	 * @method core.treeModel.Node#hasAttribute
 	 * @param {String} key Key of attribute to check.
 	 * @returns {Boolean} `true` if attribute with given key is set on node, `false` otherwise.
 	 */
@@ -178,7 +161,6 @@ export default class Node {
 	/**
 	 * Gets an attribute value for given key or undefined if that attribute is not set on node.
 	 *
-	 * @method core.treeModel.Node#getAttribute
 	 * @param {String} key Key of attribute to look for.
 	 * @returns {*} Attribute value or null.
 	 */
@@ -189,10 +171,23 @@ export default class Node {
 	/**
 	 * Returns iterator that iterates over this node attributes.
 	 *
-	 * @method core.treeModel.Node#getAttributes
 	 * @returns {Iterable.<*>}
 	 */
 	getAttributes() {
 		return this._attrs[ Symbol.iterator ]();
 	}
+
+	/**
+	 * Custom toJSON method to solve child-parent circular dependencies.
+	 *
+	 * @returns {Object} Clone of this object with the parent property replaced with its name.
+	 */
+	toJSON() {
+		const json = clone( this );
+
+		// Due to circular references we need to remove parent reference.
+		json.parent = this.parent ? this.parent.name : null;
+
+		return json;
+	}
 }

+ 2 - 2
packages/ckeditor5-engine/src/treemodel/nodelist.js

@@ -109,7 +109,7 @@ export default class NodeList {
 		 * Internal array to store nodes.
 		 *
 		 * @protected
-		 * @type {Array}
+		 * @member {Array} core.treeModel.NodeList#_nodes
 		 */
 		this._nodes = [];
 
@@ -119,7 +119,7 @@ export default class NodeList {
 		 * which occupy multiple slots in `_indexMap`.
 		 *
 		 * @private
-		 * @type {Array}
+		 * @member {Array} core.treeModel.NodeList#_indexMap
 		 */
 		this._indexMap = [];
 

+ 1 - 1
packages/ckeditor5-engine/src/treemodel/operation/attributeoperation.js

@@ -102,7 +102,7 @@ export default class AttributeOperation extends Operation {
 				 */
 				throw new CKEditorError(
 					'operation-attribute-no-attr-to-remove: The attribute which should be removed does not exists for given node.',
-					{ item: item, key: this.key, value: this.oldValue }
+					{ item: item, key: this.key }
 				);
 			}
 

+ 0 - 3
packages/ckeditor5-engine/src/treemodel/operation/moveoperation.js

@@ -51,9 +51,6 @@ export default class MoveOperation extends Operation {
 		this.targetPosition = Position.createFromPosition( targetPosition );
 	}
 
-	/**
-	 * @see core.treeModel.operation.Operation#type
-	 */
 	get type() {
 		return 'move';
 	}

+ 0 - 6
packages/ckeditor5-engine/src/treemodel/operation/reinsertoperation.js

@@ -28,16 +28,10 @@ export default class ReinsertOperation extends MoveOperation {
 		return new RemoveOperation( this.targetPosition, this.howMany, this.baseVersion + 1 );
 	}
 
-	/**
-	 * @see core.treeModel.operation.Operation#type
-	 */
 	get type() {
 		return 'reinsert';
 	}
 
-	/**
-	 * @see core.treeModel.operation.MoveOperation#isSticky
-	 */
 	get isSticky() {
 		return false;
 	}

+ 0 - 6
packages/ckeditor5-engine/src/treemodel/operation/removeoperation.js

@@ -31,16 +31,10 @@ export default class RemoveOperation extends MoveOperation {
 		super( position, howMany, graveyardPosition, baseVersion );
 	}
 
-	/**
-	 * @see core.treeModel.operation.Operation#type
-	 */
 	get type() {
 		return 'remove';
 	}
 
-	/**
-	 * @see core.treeModel.operation.MoveOperation#isSticky
-	 */
 	get isSticky() {
 		return false;
 	}

+ 125 - 0
packages/ckeditor5-engine/src/treemodel/operation/rootattributeoperation.js

@@ -0,0 +1,125 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Operation from './operation.js';
+import CKEditorError from '../../../utils/ckeditorerror.js';
+
+/**
+ * Operation to change root element's attribute. Using this class you can add, remove or change value of the attribute.
+ *
+ * This operation is needed, because root elements can't be changed through {@link core.treeModel.operation.AttributeOperation}.
+ * It is because {@link core.treeModel.operation.AttributeOperation} requires a range to change and root element can't
+ * be a part of range because every {@link core.treeModel.Position} has to be inside a root. {@link core.treeModel.Position}
+ * can't be created before a root element.
+ *
+ * @memberOf core.treeModel.operation
+ * @extends core.treeModel.operation.Operation
+ */
+export default class RootAttributeOperation extends Operation {
+	/**
+	 * Creates an operation that changes, removes or adds attributes on root element.
+	 *
+	 * @see core.treeModel.operation.AttributeOperation
+	 * @param {core.treeModel.RootElement} root Root element to change.
+	 * @param {String} key Key of an attribute to change or remove.
+	 * @param {*} oldValue Old value of the attribute with given key or `null` if adding a new attribute.
+	 * @param {*} newValue New value to set for the attribute. If `null`, then the operation just removes the attribute.
+	 * @param {Number} baseVersion {@link core.treeModel.Document#version} on which the operation can be applied.
+	 */
+	constructor( root, key, oldValue, newValue, baseVersion ) {
+		super( baseVersion );
+
+		/**
+		 * Root element to change.
+		 *
+		 * @readonly
+		 * @member {core.treeModel.RootElement} core.treeModel.operation.RootAttributeOperation#root
+		 */
+		this.root = root;
+
+		/**
+		 * Key of an attribute to change or remove.
+		 *
+		 * @readonly
+		 * @member {String} core.treeModel.operation.RootAttributeOperation#key
+		 */
+		this.key = key;
+
+		/**
+		 * Old value of the attribute with given key or `null` if adding a new attribute.
+		 *
+		 * @readonly
+		 * @member {*} core.treeModel.operation.RootAttributeOperation#oldValue
+		 */
+		this.oldValue = oldValue;
+
+		/**
+		 * New value to set for the attribute. If `null`, then the operation just removes the attribute.
+		 *
+		 * @readonly
+		 * @member {*} core.treeModel.operation.RootAttributeOperation#newValue
+		 */
+		this.newValue = newValue;
+	}
+
+	get type() {
+		return 'rootattribute';
+	}
+
+	/**
+	 * @returns {core.treeModel.operation.RootAttributeOperation}
+	 */
+	clone() {
+		return new RootAttributeOperation( this.root, this.key, this.oldValue, this.newValue, this.baseVersion );
+	}
+
+	/**
+	 * @returns {core.treeModel.operation.RootAttributeOperation}
+	 */
+	getReversed() {
+		return new RootAttributeOperation( this.root, this.key, this.newValue, this.oldValue, this.baseVersion + 1 );
+	}
+
+	_execute() {
+		if ( this.oldValue !== null && this.root.getAttribute( this.key ) !== this.oldValue ) {
+			/**
+			 * The attribute which should be removed does not exists for the given node.
+			 *
+			 * @error operation-rootattribute-no-attr-to-remove
+			 * @param {core.treeModel.RootElement} root
+			 * @param {String} key
+			 * @param {*} value
+			 */
+			throw new CKEditorError(
+				'operation-rootattribute-no-attr-to-remove: The attribute which should be removed does not exists for given node.',
+				{ root: this.root, key: this.key }
+			);
+		}
+
+		if ( this.oldValue === null && this.newValue !== null && this.root.hasAttribute( this.key ) ) {
+			/**
+			 * The attribute with given key already exists for the given node.
+			 *
+			 * @error operation-rootattribute-attr-exists
+			 * @param {core.treeModel.RootElement} root
+			 * @param {String} key
+			 */
+			throw new CKEditorError(
+				'operation-rootattribute-attr-exists: The attribute with given key already exists.',
+				{ root: this.root, key: this.key }
+			);
+		}
+
+		if ( this.newValue !== null ) {
+			this.root.setAttribute( this.key, this.newValue );
+		} else {
+			this.root.removeAttribute( this.key );
+		}
+
+		return { root: this.root, key: this.key, oldValue: this.oldValue, newValue: this.newValue };
+	}
+}

+ 31 - 0
packages/ckeditor5-engine/src/treemodel/operation/transform.js

@@ -7,6 +7,7 @@
 
 import InsertOperation from './insertoperation.js';
 import AttributeOperation from './attributeoperation.js';
+import RootAttributeOperation from './rootattributeoperation.js';
 import MoveOperation from './moveoperation.js';
 import RemoveOperation from './removeoperation.js';
 import NoOperation from './nooperation.js';
@@ -72,6 +73,8 @@ const ot = {
 
 		AttributeOperation: doNotUpdate,
 
+		RootAttributeOperation: doNotUpdate,
+
 		// Transforms InsertOperation `a` by MoveOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
 		MoveOperation( a, b, isStrong ) {
@@ -133,6 +136,8 @@ const ot = {
 			}
 		},
 
+		RootAttributeOperation: doNotUpdate,
+
 		// Transforms AttributeOperation `a` by MoveOperation `b`. Returns results as an array of operations.
 		MoveOperation( a, b ) {
 			// Convert MoveOperation properties into a range.
@@ -185,6 +190,26 @@ const ot = {
 		}
 	},
 
+	RootAttributeOperation: {
+		InsertOperation: doNotUpdate,
+
+		AttributeOperation: doNotUpdate,
+
+		// Transforms RootAttributeOperation `a` by RootAttributeOperation `b`. Accepts a flag stating whether `a` is more important
+		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
+		RootAttributeOperation( a, b, isStrong ) {
+			if ( a.root === b.root && a.key === b.key ) {
+				if ( ( a.newValue !== b.newValue && !isStrong ) || a.newValue === b.newValue ) {
+					return [ new NoOperation( a.baseVersion ) ];
+				}
+			}
+
+			return [ a.clone() ];
+		},
+
+		MoveOperation: doNotUpdate
+	},
+
 	MoveOperation: {
 		// Transforms MoveOperation `a` by InsertOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
@@ -205,6 +230,8 @@ const ot = {
 
 		AttributeOperation: doNotUpdate,
 
+		RootAttributeOperation: doNotUpdate,
+
 		// Transforms MoveOperation `a` by MoveOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
 		MoveOperation( a, b, isStrong ) {
@@ -315,6 +342,8 @@ function transform( a, b, isStrong ) {
 		group = ot.InsertOperation;
 	} else if ( a instanceof AttributeOperation ) {
 		group = ot.AttributeOperation;
+	} else if ( a instanceof RootAttributeOperation ) {
+		group = ot.RootAttributeOperation;
 	} else if ( a instanceof MoveOperation ) {
 		group = ot.MoveOperation;
 	} else {
@@ -326,6 +355,8 @@ function transform( a, b, isStrong ) {
 			algorithm = group.InsertOperation;
 		} else if ( b instanceof AttributeOperation ) {
 			algorithm = group.AttributeOperation;
+		} else if ( b instanceof RootAttributeOperation ) {
+			algorithm = group.RootAttributeOperation;
 		} else if ( b instanceof MoveOperation ) {
 			algorithm = group.MoveOperation;
 		} else {

+ 4 - 2
packages/ckeditor5-engine/src/treemodel/position.js

@@ -37,7 +37,7 @@ export default class Position {
 		/**
 		 * Root element for the path. Note that this element can not have a parent.
 		 *
-		 * @type {core.treeModel.RootElement}
+		 * @member {core.treeModel.RootElement} core.treeModel.Position#root
 		 */
 		this.root = root;
 
@@ -66,7 +66,7 @@ export default class Position {
 		 *        |- a   Before: [ 1, 1, 1 ] After: [ 1, 1, 2 ]
 		 *        |- r   Before: [ 1, 1, 2 ] After: [ 1, 1, 3 ]
 		 *
-		 * @type {Number[]}
+		 * @member {Array.<Number>} core.treeModel.Position#path
 		 */
 		this.path = path;
 	}
@@ -103,6 +103,8 @@ export default class Position {
 
 	/**
 	 * Sets offset in the parent, which is the last element of the path.
+	 *
+	 * @param {Number} newOffset
 	 */
 	set offset( newOffset ) {
 		this.path[ this.path.length - 1 ] = newOffset;

+ 1 - 1
packages/ckeditor5-engine/src/treemodel/range.js

@@ -52,7 +52,7 @@ export default class Range {
 	/**
 	 * Returns whether this range is flat, that is if start position and end position are in the same parent.
 	 *
-	 * @returns {Boolean}
+	 * @type {Boolean}
 	 */
 	get isFlat() {
 		return this.start.parent === this.end.parent;

+ 4 - 3
packages/ckeditor5-engine/src/treemodel/rootelement.js

@@ -17,10 +17,11 @@ export default class RootElement extends Element {
 	/**
 	 * Creates tree root node.
 	 *
-	 * @param {Document} doc {@link Document} that is an owner of the root.
+	 * @param {core.treeModel.Document} doc {@link core.treeModel.Document} that is an owner of the root.
+	 * @param {String} name Node name.
 	 */
-	constructor( doc ) {
-		super( 'root' );
+	constructor( doc, name ) {
+		super( name );
 
 		/**
 		 * {@link core.treeModel.Document} that is an owner of this root.

+ 416 - 0
packages/ckeditor5-engine/src/treemodel/schema.js

@@ -0,0 +1,416 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import clone from '../../utils/lib/lodash/clone.js';
+import CKEditorError from '../../utils/ckeditorerror.js';
+
+/**
+ * SchemaItem is a singular registry item in {@link core.treeModel.Schema} that groups and holds allow/disallow rules for
+ * one entity. This class is used internally in {@link core.treeModel.Schema} and should not be used outside it.
+ *
+ * @see core.treeModel.Schema
+ * @protected
+ * @memberOf core.treeModel
+ */
+export class SchemaItem {
+	/**
+	 * Creates SchemaItem instance.
+	 *
+	 * @param {core.treeModel.Schema} schema Schema instance that owns this item.
+	 */
+	constructor( schema ) {
+		/**
+		 * Schema instance that owns this item.
+		 *
+		 * @private
+		 * @member {core.treeModel.Schema} core.treeModel.SchemaItem#_schema
+		 */
+		this._schema = schema;
+
+		/**
+		 * Paths in which the entity, represented by this item, is allowed.
+		 *
+		 * @private
+		 * @member {Array} core.treeModel.SchemaItem#_allowed
+		 */
+		this._allowed = [];
+
+		/**
+		 * Paths in which the entity, represented by this item, is disallowed.
+		 *
+		 * @private
+		 * @member {Array} core.treeModel.SchemaItem#_disallowed
+		 */
+		this._disallowed = [];
+	}
+
+	/**
+	 * Allows entity, represented by this item, to be in given path.
+	 *
+	 * @param {Array.<String>|String} path Path in which entity is allowed. String with item names separated by spaces may be passed.
+	 * @param {String} [attribute] If set, this path will be used only for entities that have an attribute with this key.
+	 */
+	allow( path, attribute ) {
+		this._addPath( '_allowed', path, attribute );
+	}
+
+	/**
+	 * Disallows entity, represented by this item, to be in given path.
+	 *
+	 * @param {Array.<String>|String} path Path in which entity is disallowed. String with item names separated by spaces may be passed.
+	 * @param {String} [attribute] If set, this path will be used only for entities that have an attribute with this key.
+	 */
+	disallow( path, attribute ) {
+		this._addPath( '_disallowed', path, attribute );
+	}
+
+	/**
+	 * Adds path to the SchemaItem instance.
+	 *
+	 * @private
+	 * @param {String} member Name of the array member into which the path will be added. Possible values are `_allowed` or `_disallowed`.
+	 * @param {Array.<String>|String} path Path to be added. String with item names separated by spaces may be passed.
+	 * @param {String} [attribute] If set, this path will be used only for entities that have an attribute with this key.
+	 */
+	_addPath( member, path, attribute ) {
+		if ( typeof path === 'string' ) {
+			path = path.split( ' ' );
+		} else {
+			path = path.slice();
+		}
+
+		this[ member ].push( { path, attribute } );
+	}
+
+	/**
+	 * Returns all paths of given type that were previously registered in the item.
+	 *
+	 * @private
+	 * @param {String} type Paths' type. Possible values are `ALLOW` or `DISALLOW`.
+	 * @param {String} [attribute] If set, only paths registered for given attribute will be returned.
+	 * @returns {Array} Paths registered in the item.
+	 */
+	_getPaths( type, attribute ) {
+		const source = type === 'ALLOW' ? this._allowed : this._disallowed;
+		const paths = [];
+
+		for ( let item of source ) {
+			if ( item.attribute === attribute ) {
+				paths.push( item.path.slice() );
+			}
+		}
+
+		return paths;
+	}
+
+	/**
+	 * Checks whether this item has any registered path of given type that matches provided path.
+	 *
+	 * @private
+	 * @param {String} type Paths' type. Possible values are `ALLOW` or `DISALLOW`.
+	 * @param {Array.<String>} checkPath Path to check.
+	 * @param {String} [attribute] If set, only paths registered for given attribute will be returned.
+	 * @returns {Boolean} `true` if item has any registered matching path, `false` otherwise.
+	 */
+	_hasMatchingPath( type, checkPath, attribute ) {
+		const itemPaths = this._getPaths( type, attribute );
+
+		// We check every path registered (possibly with given attribute) in the item.
+		for ( let itemPath of itemPaths ) {
+			// We have one of paths registered in the item.
+
+			// Now we have to check every item name from the path to check.
+			for ( let checkName of checkPath ) {
+				// Every item name is expanded to all names of items that item is extending.
+				// So, if on item path, there is an item that is extended by item from checked path, it will
+				// also be treated as matching.
+				const chain = this._schema._extensionChains.get( checkName );
+
+				// Since our paths have to match in given order, we always check against first item from item path.
+				// So, if item path is: B D E
+				// And checked path is: A B C D E
+				// It will be matching (A won't match, B will match, C won't match, D and E will match)
+				if ( chain.indexOf( itemPath[ 0 ] ) > -1 ) {
+					// Every time we have a match, we remove it from `itemPath` so we can still check against first item.
+					itemPath.shift();
+				}
+			}
+
+			// If `itemPath` has no items it means that we removed all of them, so we matched all of them.
+			// This means that we found a matching path.
+			if ( itemPath.length === 0 ) {
+				return true;
+			}
+		}
+
+		// No matching path found.
+		return false;
+	}
+
+	/**
+	 * Custom toJSON method to solve child-parent circular dependencies.
+	 *
+	 * @returns {Object} Clone of this object with the parent property replaced with its name.
+	 */
+	toJSON() {
+		const json = clone( this );
+
+		// Due to circular references we need to remove parent reference.
+		json._schema = '[treeModel.Schema]';
+
+		return json;
+	}
+}
+
+/**
+ * Schema is a definition of the structure of the document. It allows to define which tree model items (element, text, etc.)
+ * can be nested within which ones and which attributes can be applied to them. It's created during the run-time of the application,
+ * typically by features. Also, the features can query the schema to learn what structure is allowed and act accordingly.
+ *
+ * For instance, if a feature wants to define that an attribute bold is allowed on the text it needs to register this rule like this:
+ *
+ *		editor.document.schema.allow( '$text', 'bold' );
+ *
+ * Note: items prefixed with `$` are special group of items. By default, `Schema` defines three special items:
+ * * `$inline` represents all inline elements,
+ * * `$text` is a sub-group of `$inline` and represents text nodes,
+ * * `$block` represents block elements.
+ *
+ * When registering an item it's possible to tell that this item should inherit from some other existing item.
+ * E.g. `p` can inherit from `$block`, so whenever given attribute is allowed on the `$block` it will automatically be
+ * also allowed on the `p` element. By default, `$text` item already inherits from `$inline`.
+ *
+ * @memberOf core.treeModel
+ */
+export default class Schema {
+	/**
+	 * Creates Schema instance.
+	 */
+	constructor() {
+		/**
+		 * Schema items registered in the schema.
+		 *
+		 * @private
+		 * @member {Map} core.treeModel.Schema#_items
+		 */
+		this._items = new Map();
+
+		/**
+		 * Description of what entities are a base for given entity.
+		 *
+		 * @private
+		 * @member {Map} core.treeModel.Schema#_extensionChains
+		 */
+		this._extensionChains = new Map();
+
+		// Register some default abstract entities.
+		this.registerItem( '$inline' );
+		this.registerItem( '$block' );
+		this.registerItem( '$text', '$inline' );
+
+		// Allow inline elements inside block elements.
+		this.allow( { name: '$inline', inside: '$block' } );
+	}
+
+	/**
+	 * Allows given query in the schema.
+	 *
+	 *		// Allow text with bold attribute in all P elements.
+	 *		schema.registerItem( 'p', '$block' );
+	 *		schema.allow( { name: '$text', attribute: 'bold', inside: 'p' } );
+	 *
+	 *		// Allow header in Ps that are in DIVs
+	 *		schema.registerItem( 'header', '$block' );
+	 *		schema.registerItem( 'div', '$block' );
+	 *		schema.allow( { name: 'header', inside: 'div p' } ); // inside: [ 'div', 'p' ] would also work.
+	 *
+	 * @param {core.treeModel.SchemaQuery} query Allowed query.
+	 */
+	allow( query ) {
+		this._getItem( query.name ).allow( query.inside, query.attribute );
+	}
+
+	/**
+	 * Disallows given query in the schema.
+	 *
+	 * @see {@link core.treeModel.Schema#allow}
+	 * @param {core.treeModel.SchemaQuery} query Disallowed query.
+	 */
+	disallow( query ) {
+		this._getItem( query.name ).disallow( query.inside, query.attribute );
+	}
+
+	/**
+	 * Checks whether entity with given name (and optionally, with given attribute) is allowed at given position.
+	 *
+	 *		// Check whether bold text can be placed at caret position.
+	 *		let caretPos = editor.document.selection.getFirstPosition();
+	 *		if ( schema.checkAtPosition( caretPos, '$text', 'bold' ) ) { ... }
+	 *
+	 * @param {core.treeModel.Position} position Position to check at.
+	 * @param {String} name Entity name to check.
+	 * @param {String} [attribute] If set, schema will check for entity with given attribute.
+	 * @returns {Boolean} `true` if entity is allowed, `false` otherwise
+	 */
+	checkAtPosition( position, name, attribute ) {
+		if ( !this.hasItem( name ) ) {
+			return false;
+		}
+
+		return this.checkQuery( {
+			name: name,
+			inside: Schema._makeItemsPathFromPosition( position ),
+			attribute: attribute
+		} );
+	}
+
+	/**
+	 * Checks whether given query is allowed in schema.
+	 *
+	 *		// Check whether bold text is allowed in header element.
+	 *		let query = {
+	 *			name: '$text',
+	 *			attribute: 'bold',
+	 *			inside: 'header'
+	 *		};
+	 *		if ( schema.checkQuery( query ) ) { ... }
+	 *
+	 * @param {core.treeModel.SchemaQuery} query Query to check.
+	 * @returns {Boolean} `true` if given query is allowed in schema, `false` otherwise.
+	 */
+	checkQuery( query ) {
+		if ( !this.hasItem( query.name ) ) {
+			return false;
+		}
+
+		const path = ( typeof query.inside === 'string' ) ? query.inside.split( ' ' ) : query.inside;
+
+		// Get extension chain of given item and retrieve all schema items that are extended by given item.
+		const schemaItems = this._extensionChains.get( query.name ).map( ( name ) => {
+			return this._getItem( name );
+		} );
+
+		// If there is matching disallow path, this query is not valid with schema.
+		for ( let schemaItem of schemaItems ) {
+			if ( schemaItem._hasMatchingPath( 'DISALLOW', path, query.attribute ) ) {
+				return false;
+			}
+		}
+
+		// At this point, the query is not disallowed.
+		// If there is any allow path that matches query, this query is valid with schema.
+		for ( let schemaItem of schemaItems ) {
+			if ( schemaItem._hasMatchingPath( 'ALLOW', path, query.attribute ) ) {
+				return true;
+			}
+		}
+
+		// There are no allow paths that matches query. The query is not valid with schema.
+		return false;
+	}
+
+	/**
+	 * Checks whether there is an item registered under given name in schema.
+	 *
+	 * @param itemName
+	 * @returns {boolean}
+	 */
+	hasItem( itemName ) {
+		return this._items.has( itemName );
+	}
+
+	/**
+	 * Registers given item name in schema.
+	 *
+	 *		// Register P element that should be treated like all block elements.
+	 *		schema.registerItem( 'p', '$block' );
+	 *
+	 * @param {String} itemName Name to register.
+	 * @param [isExtending] If set, new item will extend item with given name.
+	 */
+	registerItem( itemName, isExtending ) {
+		if ( this.hasItem( itemName ) ) {
+			/**
+			 * Item with specified name already exists in schema.
+			 *
+			 * @error schema-item-exists
+			 */
+			throw new CKEditorError( 'schema-item-exists: Item with specified name already exists in schema.' );
+		}
+
+		if ( !!isExtending && !this.hasItem( isExtending ) ) {
+			/**
+			 * Item with specified name does not exist in schema.
+			 *
+			 * @error schema-no-item
+			 */
+			throw new CKEditorError( 'schema-no-item: Item with specified name does not exist in schema.' );
+		}
+
+		// Create new SchemaItem and add it to the items store.
+		this._items.set( itemName, new SchemaItem( this ) );
+
+		// Create an extension chain.
+		// Extension chain has all item names that should be checked when that item is on path to check.
+		// This simply means, that if item is not extending anything, it should have only itself in it's extension chain.
+		// Since extending is not dynamic, we can simply get extension chain of extended item and expand it with registered name,
+		// if the registered item is extending something.
+		const chain = this.hasItem( isExtending ) ? this._extensionChains.get( isExtending ).concat( itemName ) : [ itemName ];
+		this._extensionChains.set( itemName, chain );
+	}
+
+	/**
+	 * Returns {@link core.treeModel.SchemaItem schema item} that was registered in the schema under given name.
+	 * If item has not been found, throws error.
+	 *
+	 * @private
+	 * @param {String} itemName Name to look for in schema.
+	 * @returns {core.treeModel.SchemaItem} Schema item registered under given name.
+	 */
+	_getItem( itemName ) {
+		if ( !this.hasItem( itemName ) ) {
+			/**
+			 * Item with specified name does not exist in schema.
+			 *
+			 * @error schema-no-item
+			 */
+			throw new CKEditorError( 'schema-no-item: Item with specified name does not exist in schema.' );
+		}
+
+		return this._items.get( itemName );
+	}
+
+	/**
+	 * Gets position and traverses through it's parents to get their names and returns them.
+	 *
+	 * @private
+	 * @param {core.treeModel.Position} position Position to start building path from.
+	 * @returns {Array.<String>} Path containing elements names from top-most to the one containing given position.
+	 */
+	static _makeItemsPathFromPosition( position ) {
+		const path = [];
+		let parent = position.parent;
+
+		while ( parent !== null ) {
+			path.push( parent.name );
+			parent = parent.parent;
+		}
+
+		path.reverse();
+
+		return path;
+	}
+}
+
+/**
+ * Object with query used by {@link core.treeModel.Schema} to query schema or add allow/disallow rules to schema.
+ *
+ * @typedef {Object} core.treeModel.SchemaQuery
+ * @property {String} name Entity name.
+ * @property {Array.<String>|String} inside Path inside which the entity is placed.
+ * @property {String} [attribute] If set, the query applies only to entities that has attribute with given key.
+ */

+ 341 - 79
packages/ckeditor5-engine/src/treemodel/selection.js

@@ -5,22 +5,32 @@
 
 'use strict';
 
+import Position from './position.js';
+import Range from './range.js';
 import LiveRange from './liverange.js';
 import EmitterMixin from '../../utils/emittermixin.js';
+import CharacterProxy from './characterproxy.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
 import utils from '../../utils/utils.js';
 
+const storePrefix = 'selection:';
+
 /**
- * Represents a selection that is made on nodes in {@link core.treeModel.Document}. Selection instance is
- * created by {@link core.treeModel.Document}. In most scenarios you should not need to create an instance of Selection.
+ * Represents a selection that is made on nodes in {@link core.treeModel.Document}. `Selection` instance is
+ * created by {@link core.treeModel.Document}. You should not need to create an instance of `Selection`.
+ *
+ * Keep in mind that selection always contains at least one range. If no ranges has been added to selection or all ranges
+ * got removed from selection, the selection will be reset to contain {@link core.treeModel.Selection#_getDefaultRange the default range}.
  *
  * @memberOf core.treeModel
  */
 export default class Selection {
 	/**
 	 * Creates an empty selection.
+	 *
+	 * @param {core.treeModel.Document} document Document which owns this selection.
 	 */
-	constructor() {
+	constructor( document ) {
 		/**
 		 * List of attributes set on current selection.
 		 *
@@ -30,12 +40,12 @@ export default class Selection {
 		this._attrs = new Map();
 
 		/**
-		 * Stores all ranges that are selected.
+		 * Document which owns this selection.
 		 *
 		 * @private
-		 * @member {Array.<core.treeModel.LiveRange>} core.treeModel.Selection#_ranges
+		 * @member {core.treeModel.Document} core.treeModel.Selection#_document
 		 */
-		this._ranges = [];
+		this._document = document;
 
 		/**
 		 * Specifies whether the last added range was added as a backward or forward range.
@@ -44,42 +54,41 @@ export default class Selection {
 		 * @member {Boolean} core.treeModel.Selection#_lastRangeBackward
 		 */
 		this._lastRangeBackward = false;
+
+		/**
+		 * Stores all ranges that are selected.
+		 *
+		 * @private
+		 * @member {Array.<core.treeModel.LiveRange>} core.treeModel.Selection#_ranges
+		 */
+		this._ranges = [];
 	}
 
 	/**
-	 * Selection anchor. Anchor may be described as a position where the selection starts.
-	 * Together with {@link #focus} they define the direction of selection, which is important
-	 * when expanding/shrinking selection. When there are no ranges in selection anchor is null.
-	 * Anchor is always a start or end of the most recent added range. It may be a bit unintuitive when
-	 * there are multiple ranges in selection.
+	 * Selection anchor. Anchor may be described as a position where the selection starts. Together with
+	 * {@link core.treeModel.Selection#focus} they define the direction of selection, which is important
+	 * when expanding/shrinking selection. Anchor is always the start or end of the most recent added range.
+	 * It may be a bit unintuitive when there are multiple ranges in selection.
 	 *
-	 * @type {core.treeModel.LivePosition|null}
+	 * @see core.treeModel.Selection#focus
+	 * @type {core.treeModel.LivePosition}
 	 */
 	get anchor() {
-		if ( this._ranges.length > 0 ) {
-			let range = this._ranges[ this._ranges.length - 1 ];
-
-			return this._lastRangeBackward ? range.end : range.start;
-		}
+		let range = this._ranges.length ? this._ranges[ this._ranges.length - 1 ] : this._getDefaultRange();
 
-		return null;
+		return this._lastRangeBackward ? range.end : range.start;
 	}
 
 	/**
-	 * Selection focus. Focus is a position where the selection ends. When there are no ranges in selection,
-	 * focus is null.
+	 * Selection focus. Focus is a position where the selection ends.
 	 *
-	 * @link {#anchor}
-	 * @type {core.treeModel.LivePosition|null}
+	 * @see core.treeModel.Selection#anchor
+	 * @type {core.treeModel.LivePosition}
 	 */
 	get focus() {
-		if ( this._ranges.length > 0 ) {
-			let range = this._ranges[ this._ranges.length - 1 ];
-
-			return this._lastRangeBackward ? range.start : range.end;
-		}
+		let range = this._ranges.length ? this._ranges[ this._ranges.length - 1 ] : this._getDefaultRange();
 
-		return null;
+		return this._lastRangeBackward ? range.start : range.end;
 	}
 
 	/**
@@ -99,26 +108,29 @@ export default class Selection {
 
 	/**
 	 * Adds a range to the selection. Added range is copied and converted to {@link core.treeModel.LiveRange}. This means
-	 * that passed range is not saved in the Selection instance and you can safely operate on it. Accepts a flag
-	 * describing in which way the selection is made - passed range might be selected from {@link core.treeModel.Range#start}
-	 * to {@link core.treeModel.Range#end} or from {@link core.treeModel.Range#start} to {@link core.treeModel.Range#end}. The flag
-	 * is used to set {@link #anchor} and {@link #focus} properties.
+	 * that passed range is not saved in the Selection instance and you can safely operate on it.
 	 *
+	 * Accepts a flag describing in which way the selection is made - passed range might be selected from
+	 * {@link core.treeModel.Range#start} to {@link core.treeModel.Range#end} or from {@link core.treeModel.Range#end}
+	 * to {@link core.treeModel.Range#start}. The flag is used to set {@link core.treeModel.Selection#anchor} and
+	 * {@link core.treeModel.Selection#focus} properties.
+	 *
+	 * @fires {@link core.treeModel.Selection#change:range change:range}
 	 * @param {core.treeModel.Range} range Range to add.
 	 * @param {Boolean} [isBackward] Flag describing if added range was selected forward - from start to end (`false`)
 	 * or backward - from end to start (`true`). Defaults to `false`.
 	 */
 	addRange( range, isBackward ) {
-		pushRange.call( this, range );
+		this._pushRange( range );
 		this._lastRangeBackward = !!isBackward;
 
-		this.fire( 'update' );
+		this.fire( 'change:range' );
 	}
 
 	/**
 	 * Unbinds all events previously bound by this selection or objects created by this selection.
 	 */
-	detach() {
+	destroy() {
 		for ( let i = 0; i < this._ranges.length; i++ ) {
 			this._ranges[ i ].detach();
 		}
@@ -131,18 +143,50 @@ export default class Selection {
 	 * @returns {Array.<LiveRange>}
 	 */
 	getRanges() {
-		return this._ranges.slice();
+		return this._ranges.length ? this._ranges.slice() : [ this._getDefaultRange() ];
+	}
+
+	/**
+	 * Returns the first range in the selection. First range is the one which {@link core.treeModel.Range#start start} position
+	 * {@link core.treeModel.Position#isBefore is before} start position of all other ranges (not to confuse with the first range
+	 * added to the selection).
+	 *
+	 * @returns {core.treeModel.Range}
+	 */
+	getFirstRange() {
+		let first = null;
+
+		for ( let i = 0; i < this._ranges.length; i++ ) {
+			let range = this._ranges[ i ];
+
+			if ( !first || range.start.isBefore( first.start ) ) {
+				first = range;
+			}
+		}
+
+		return first ? Range.createFromRange( first ) : this._getDefaultRange();
+	}
+
+	/**
+	 * Returns the first position in the selection. First position is the position that {@link core.treeModel.Position#isBefore is before}
+	 * any other position in the selection ranges.
+	 *
+	 * @returns {core.treeModel.Position}
+	 */
+	getFirstPosition() {
+		return Position.createFromPosition( this.getFirstRange().start );
 	}
 
 	/**
 	 * Removes all ranges that were added to the selection. Fires update event.
 	 *
+	 * @fires {@link core.treeModel.Selection#change:range change:range}
 	 */
 	removeAllRanges() {
-		this.detach();
+		this.destroy();
 		this._ranges = [];
 
-		this.fire( 'update' );
+		this.fire( 'change:range' );
 	}
 
 	/**
@@ -150,30 +194,34 @@ export default class Selection {
 	 * is treated like the last added range and is used to set {@link #anchor} and {@link #focus}. Accepts a flag
 	 * describing in which way the selection is made (see {@link #addRange}).
 	 *
+	 * @fires {@link core.treeModel.Selection#change:range change:range}
 	 * @param {Array.<core.treeModel.Range>} newRanges Array of ranges to set.
 	 * @param {Boolean} [isLastBackward] Flag describing if last added range was selected forward - from start to end (`false`)
 	 * or backward - from end to start (`true`). Defaults to `false`.
 	 */
 	setRanges( newRanges, isLastBackward ) {
-		this.detach();
+		this.destroy();
 		this._ranges = [];
 
 		for ( let i = 0; i < newRanges.length; i++ ) {
-			pushRange.call( this, newRanges[ i ] );
+			this._pushRange( newRanges[ i ] );
 		}
 
 		this._lastRangeBackward = !!isLastBackward;
-		this.fire( 'update' );
+
+		this.fire( 'change:range' );
 	}
 
 	/**
-	 * Checks if the selection has an attribute for given key.
+	 * Removes all attributes from the selection.
 	 *
-	 * @param {String} key Key of attribute to check.
-	 * @returns {Boolean} `true` if attribute with given key is set on selection, `false` otherwise.
+	 * @fires {@link core.treeModel.Selection#change:range change:range}
 	 */
-	hasAttribute( key ) {
-		return this._attrs.has( key );
+	clearAttributes() {
+		this._attrs.clear();
+		this._setStoredAttributesTo( new Map() );
+
+		this.fire( 'change:attribute' );
 	}
 
 	/**
@@ -195,71 +243,285 @@ export default class Selection {
 		return this._attrs[ Symbol.iterator ]();
 	}
 
+	/**
+	 * Checks if the selection has an attribute for given key.
+	 *
+	 * @param {String} key Key of attribute to check.
+	 * @returns {Boolean} `true` if attribute with given key is set on selection, `false` otherwise.
+	 */
+	hasAttribute( key ) {
+		return this._attrs.has( key );
+	}
+
+	/**
+	 * Removes an attribute with given key from the selection.
+	 *
+	 * @fires {@link core.treeModel.Selection#change:range change:range}
+	 * @param {String} key Key of attribute to remove.
+	 */
+	removeAttribute( key ) {
+		this._attrs.delete( key );
+		this._removeStoredAttribute( key );
+
+		this.fire( 'change:attribute' );
+	}
+
 	/**
 	 * Sets attribute on the selection. If attribute with the same key already is set, it overwrites its values.
 	 *
+	 * @fires {@link core.treeModel.Selection#change:range change:range}
 	 * @param {String} key Key of attribute to set.
 	 * @param {*} value Attribute value.
 	 */
 	setAttribute( key, value ) {
 		this._attrs.set( key, value );
+		this._storeAttribute( key, value );
+
+		this.fire( 'change:attribute' );
 	}
 
 	/**
 	 * Removes all attributes from the selection and sets given attributes.
 	 *
+	 * @fires {@link core.treeModel.Selection#change:range change:range}
 	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
 	 */
 	setAttributesTo( attrs ) {
 		this._attrs = utils.toMap( attrs );
+		this._setStoredAttributesTo( this._attrs );
+
+		this.fire( 'change:attribute' );
 	}
 
 	/**
-	 * Removes an attribute with given key from the selection.
+	 * Converts given range to {@link core.treeModel.LiveRange} and adds it to internal ranges array. Throws an error
+	 * if given range is intersecting with any range that is already stored in this selection.
 	 *
+	 * @private
+	 * @param {core.treeModel.Range} range Range to add.
+	 */
+	_pushRange( range ) {
+		for ( let i = 0; i < this._ranges.length ; i++ ) {
+			if ( range.isIntersecting( this._ranges[ i ] ) ) {
+				/**
+				 * Trying to add a range that intersects with another range from selection.
+				 *
+				 * @error selection-range-intersects
+				 * @param {core.treeModel.Range} addedRange Range that was added to the selection.
+				 * @param {core.treeModel.Range} intersectingRange Range from selection that intersects with `addedRange`.
+				 */
+				throw new CKEditorError(
+					'selection-range-intersects: Trying to add a range that intersects with another range from selection.',
+					{ addedRange: range, intersectingRange: this._ranges[ i ] }
+				);
+			}
+		}
+
+		this._ranges.push( LiveRange.createFromRange( range ) );
+	}
+
+	/**
+	 * Iterates through all attributes stored in current selection's parent.
+	 *
+	 * @returns {Iterable.<*>}
+	 */
+	*_getStoredAttributes() {
+		const selectionParent = this.getFirstPosition().parent;
+
+		if ( this.isCollapsed && selectionParent.getChildCount() === 0 ) {
+			for ( let attr of selectionParent.getAttributes() ) {
+				if ( attr[ 0 ].indexOf( storePrefix ) === 0 ) {
+					const realKey = attr[ 0 ].substr( storePrefix.length );
+
+					yield [ realKey, attr[ 1 ] ];
+				}
+			}
+		}
+	}
+
+	/**
+	 * Removes attribute with given key from attributes stored in current selection's parent node.
+	 *
+	 * @private
 	 * @param {String} key Key of attribute to remove.
-	 * @returns {Boolean} `true` if the attribute was set on the selection, `false` otherwise.
 	 */
-	removeAttribute( key ) {
-		return this._attrs.delete( key );
+	_removeStoredAttribute( key ) {
+		const selectionParent = this.getFirstPosition().parent;
+
+		if ( this.isCollapsed && selectionParent.getChildCount() === 0 ) {
+			const storeKey = Selection._getStoreAttributeKey( key );
+
+			this._document.enqueueChanges( () => {
+				this._document.batch().removeAttr( storeKey, selectionParent );
+			} );
+		}
 	}
 
 	/**
-	 * Removes all attributes from the selection.
+	 * Stores given attribute key and value in current selection's parent node if the selection is collapsed and
+	 * the parent node is empty.
+	 *
+	 * @private
+	 * @param {String} key Key of attribute to set.
+	 * @param {*} value Attribute value.
 	 */
-	clearAttributes() {
-		this._attrs.clear();
+	_storeAttribute( key, value ) {
+		const selectionParent = this.getFirstPosition().parent;
+
+		if ( this.isCollapsed && selectionParent.getChildCount() === 0 ) {
+			const storeKey = Selection._getStoreAttributeKey( key );
+
+			this._document.enqueueChanges( () => {
+				this._document.batch().setAttr( storeKey, value, selectionParent );
+			} );
+		}
 	}
-}
 
-/**
- * Converts given range to {@link core.treeModel.LiveRange} and adds it to internal ranges array. Throws an error
- * if given range is intersecting with any range that is already stored in this selection.
- *
- * @private
- * @method pushRange
- * @memberOf {core.treeModel.Selection}
- * @param {core.treeModel.Range} range Range to add.
- */
-function pushRange( range ) {
-	/* jshint validthis: true */
-	for ( let i = 0; i < this._ranges.length ; i++ ) {
-		if ( range.isIntersecting( this._ranges[ i ] ) ) {
-			/**
-			 * Trying to add a range that intersects with another range from selection.
-			 *
-			 * @error selection-range-intersects
-			 * @param {core.treeModel.Range} addedRange Range that was added to the selection.
-			 * @param {core.treeModel.Range} intersectingRange Range from selection that intersects with `addedRange`.
-			 */
-			throw new CKEditorError(
-				'selection-range-intersects: Trying to add a range that intersects with another range from selection.',
-				{ addedRange: range, intersectingRange: this._ranges[ i ] }
-			);
+	/**
+	 * Sets selection attributes stored in current selection's parent node to given set of attributes.
+	 *
+	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
+	 * @private
+	 */
+	_setStoredAttributesTo( attrs ) {
+		const selectionParent = this.getFirstPosition().parent;
+
+		if ( this.isCollapsed && selectionParent.getChildCount() === 0 ) {
+			this._document.enqueueChanges( () => {
+				const batch = this._document.batch();
+
+				for ( let attr of this._getStoredAttributes() ) {
+					const storeKey = Selection._getStoreAttributeKey( attr[ 0 ] );
+
+					batch.removeAttr( storeKey, selectionParent );
+				}
+
+				for ( let attr of attrs ) {
+					const storeKey = Selection._getStoreAttributeKey( attr[ 0 ] );
+
+					batch.setAttr( storeKey, attr[ 1 ], selectionParent );
+				}
+			} );
+		}
+	}
+
+	/**
+	 * Updates this selection attributes based on it's position in the Tree Model.
+	 *
+	 * @protected
+	 */
+	_updateAttributes() {
+		const position = this.getFirstPosition();
+		const positionParent = position.parent;
+
+		let attrs = null;
+
+		if ( this.isCollapsed === false ) {
+			// 1. If selection is a range...
+			const range = this.getFirstRange();
+
+			// ...look for a first character node in that range and take attributes from it.
+			for ( let item of range ) {
+				if ( item.type == 'TEXT' ) {
+					attrs = item.item.getAttributes();
+					break;
+				}
+			}
+		}
+
+		// 2. If the selection is a caret or the range does not contain a character node...
+		if ( !attrs && this.isCollapsed === true ) {
+			const nodeBefore = positionParent.getChild( position.offset - 1 );
+			const nodeAfter = positionParent.getChild( position.offset );
+
+			// ...look at the node before caret and take attributes from it if it is a character node.
+			attrs = getAttrsIfCharacter( nodeBefore );
+
+			// 3. If not, look at the node after caret...
+			if ( !attrs ) {
+				attrs = getAttrsIfCharacter( nodeAfter );
+			}
+
+			// 4. If not, try to find the first character on the left, that is in the same node.
+			if ( !attrs ) {
+				let node = nodeBefore;
+
+				while ( node && !attrs ) {
+					node = node.previousSibling;
+					attrs = getAttrsIfCharacter( node );
+				}
+			}
+
+			// 5. If not found, try to find the first character on the right, that is in the same node.
+			if ( !attrs ) {
+				let node = nodeAfter;
+
+				while ( node && !attrs ) {
+					node = node.nextSibling;
+					attrs = getAttrsIfCharacter( node );
+				}
+			}
+
+			// 6. If not found, selection should retrieve attributes from parent.
+			if ( !attrs ) {
+				attrs = this._getStoredAttributes();
+			}
+		}
+
+		if ( attrs ) {
+			this._attrs = new Map( attrs );
+		} else {
+			this.clearAttributes();
+		}
+
+		function getAttrsIfCharacter( node ) {
+			if ( node instanceof CharacterProxy ) {
+				return node.getAttributes();
+			}
+
+			return null;
 		}
+
+		this.fire( 'change:attribute' );
+	}
+
+	/**
+	 * Returns a default range for this selection. The default range is a collapsed range that starts and ends
+	 * at the beginning of this selection's document {@link core.treeModel.Document#_getDefaultRoot default root}.
+	 * This "artificial" range is important for algorithms that base on selection, so they won't break or need
+	 * special logic if there are no real ranges in the selection.
+	 *
+	 * @private
+	 * @returns {core.treeModel.Range}
+	 */
+	_getDefaultRange() {
+		const defaultRoot = this._document._getDefaultRoot();
+
+		return new Range( new Position( defaultRoot, [ 0 ] ), new Position( defaultRoot, [ 0 ] ) );
 	}
 
-	this._ranges.push( LiveRange.createFromRange( range ) );
+	/**
+	 * Generates and returns an attribute key for selection attributes store, basing on original attribute key.
+	 *
+	 * @param {String} key Attribute key to convert.
+	 * @returns {String} Converted attribute key, applicable for selection store.
+	 */
+	static _getStoreAttributeKey( key ) {
+		return storePrefix + key;
+	}
 }
 
 utils.mix( Selection, EmitterMixin );
+
+/**
+ * Fired whenever selection ranges are changed through {@link core.treeModel.Selection Selection API}. Not fired when
+ * {@link core.treeModel.LiveRange live ranges} inserted in selection change because of Tree Model changes.
+ *
+ * @event core.treeModel.Selection#change:range
+ */
+
+/**
+ * Fired whenever selection attributes are changed.
+ *
+ * @event core.treeModel.Selection#change:attribute
+ */

+ 3 - 3
packages/ckeditor5-engine/src/treemodel/textfragment.js

@@ -33,7 +33,7 @@ export default class TextFragment {
 		 * First character node contained in {@link core.treeModel.TextFragment}.
 		 *
 		 * @readonly
-		 * @type {core.treeModel.CharacterProxy}
+		 * @member {core.treeModel.CharacterProxy} core.treeModel.TextFragment#first
 		 */
 		this.first = firstCharacter;
 
@@ -41,7 +41,7 @@ export default class TextFragment {
 		 * Characters contained in {@link core.treeModel.TextFragment}.
 		 *
 		 * @readonly
-		 * @type {String}
+		 * @member {String} core.treeModel.TextFragment#text
 		 */
 		this.text = firstCharacter._nodeListText.text.substr( this.first._index, length );
 
@@ -49,7 +49,7 @@ export default class TextFragment {
 		 * Last {@link core.treeModel.CharacterProxy character node} contained in {@link core.treeModel.TextFragment}.
 		 *
 		 * @readonly
-		 * @type {core.treeModel.CharacterProxy}
+		 * @member {core.treeModel.CharacterProxy} core.treeModel.TextFragment#last
 		 */
 		this.last = this.getCharAt( this.text.length - 1 );
 	}

+ 1 - 1
packages/ckeditor5-engine/src/treeview/domconverter.js

@@ -13,7 +13,7 @@ import ViewElement from './element.js';
  * {@link core.treeView.DomConverter#bindElements binding} these nodes.
  *
  * DomConverter does not check which nodes should be rendered (use {@link core.treeView.Renderer}), does not keep a
- * state of a tree nor keeps synchronization between tree view and DOM tree (use {@link @core.treeView.TreeView}).
+ * state of a tree nor keeps synchronization between tree view and DOM tree (use {@link core.treeView.TreeView}).
  *
  * DomConverter keeps DOM elements to View element bindings, so when the converter will be destroyed, the binding will
  * be lost. Two converters will keep separate binding maps, so one tree view can be bound with two DOM trees.

+ 1 - 1
packages/ckeditor5-engine/src/treeview/element.js

@@ -19,7 +19,7 @@ export default class Element extends Node {
 	/**
 	 * Creates a tree view element.
 	 *
-	 * Attributes can be passes in various formats:
+	 * Attributes can be passed in various formats:
 	 *
 	 *		new Element( 'div', { 'class': 'editor', 'contentEditable': 'true' } ); // object
 	 *		new Element( 'div', [ [ 'class', 'editor' ], [ 'contentEditable', 'true' ] ] ); // map-like iterator

+ 2 - 4
packages/ckeditor5-engine/src/treeview/position.js

@@ -21,16 +21,14 @@ export default class Position {
 		/**
 		 * Position parent element.
 		 *
-		 * @member core.treeView.Position#parent
-		 * @type {core.treeView.Element}
+		 * @member {core.treeView.Element} core.treeView.Position#parent
 		 */
 		this.parent = parent;
 
 		/**
 		 * Position offset.
 		 *
-		 * @member core.treeView.Position#offset
-		 * @type {Number}
+		 * @member {Number} core.treeView.Position#offset
 		 */
 		this.offset = offset;
 	}

+ 1 - 1
packages/ckeditor5-engine/src/treeview/renderer.js

@@ -101,7 +101,7 @@ export default class Renderer {
 	 * {@link core.treeView.Renderer#markedTexts} and updated all nodes which needs to be updated. Then it clear all three
 	 * sets.
 	 *
-	 * Renderer try not to bread IME, so it do as little as it is possible to update DOM.
+	 * Renderer try not to break IME, so it do as little as it is possible to update DOM.
 	 *
 	 * For attributes it adds new attributes to DOM elements, update attributes with different values and remove
 	 * attributes which does not exists in the view element.

+ 0 - 3
packages/ckeditor5-engine/src/treeview/treeview.js

@@ -90,7 +90,6 @@ export default class TreeView {
 	 * Adds an observer to the set of observers. This method also {@link core.treeView.Observer#init initializes} and
 	 * {@link core.treeView.Observer#attach attaches} the observer.
 	 *
-	 * @method core.treeView.TreeView#addObserver
 	 * @param {core.treeView.Observer} observer Observer to add.
 	 */
 	addObserver( observer ) {
@@ -102,8 +101,6 @@ export default class TreeView {
 	/**
 	 * Renders all changes. In order to avoid triggering the observers (e.g. mutations) all observers all detached
 	 * before rendering and reattached after that.
-	 *
-	 * @method core.treeView.TreeView#render
 	 */
 	render() {
 		for ( let observer of this.observers ) {

+ 252 - 0
packages/ckeditor5-engine/tests/command/attributecommand.js

@@ -0,0 +1,252 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Editor from '/ckeditor5/core/editor.js';
+import AttributeCommand from '/ckeditor5/core/command/attributecommand.js';
+import Text from '/ckeditor5/core/treemodel/text.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Element from '/ckeditor5/core/treemodel/element.js';
+
+let element, editor, command, modelDoc, root;
+
+const attrKey = 'bold';
+
+beforeEach( () => {
+	element = document.createElement( 'div' );
+	document.body.appendChild( element );
+
+	editor = new Editor( element );
+	modelDoc = editor.document;
+	root = modelDoc.createRoot( 'root', 'div' );
+
+	command = new AttributeCommand( editor, attrKey );
+
+	modelDoc.schema.registerItem( 'div', '$block' );
+	modelDoc.schema.registerItem( 'p', '$block' );
+	modelDoc.schema.registerItem( 'img', '$inline' );
+
+	// Allow block in "root" (DIV)
+	modelDoc.schema.allow( { name: '$block', inside: 'div' } );
+
+	// Bold text is allowed only in P.
+	modelDoc.schema.allow( { name: '$text', attribute: 'bold', inside: 'p' } );
+	modelDoc.schema.allow( { name: 'p', attribute: 'bold', inside: 'div' } );
+
+	// Disallow bold on image.
+	modelDoc.schema.disallow( { name: 'img', attribute: 'bold', inside: 'div' } );
+} );
+
+describe( 'value', () => {
+	it( 'should be set to true or false basing on selection attribute', () => {
+		modelDoc.selection.setAttribute( attrKey, true );
+		expect( command.value ).to.be.true;
+
+		modelDoc.selection.removeAttribute( attrKey );
+		expect( command.value ).to.be.false;
+	} );
+} );
+
+describe( '_doExecute', () => {
+	let p;
+
+	beforeEach( () => {
+		let attrs = {};
+		attrs[ attrKey ] = true;
+
+		root.insertChildren( 0, [ new Element( 'p', [] , [ 'abc', new Text( 'foobar', attrs ), 'xyz' ] ), new Element( 'p' ) ] );
+		p = root.getChild( 0 );
+	} );
+
+	it( 'should add attribute on selected nodes if the command value was false', () => {
+		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 5 ] ) ) );
+
+		expect( command.value ).to.be.false;
+
+		command._doExecute();
+
+		expect( command.value ).to.be.true;
+		expect( p.getChild( 1 ).hasAttribute( attrKey ) ).to.be.true;
+		expect( p.getChild( 2 ).hasAttribute( attrKey ) ).to.be.true;
+	} );
+
+	it( 'should remove attribute from selected nodes if the command value was true', () => {
+		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 3 ] ), new Position( root, [ 0, 6 ] ) ) );
+
+		expect( command.value ).to.be.true;
+
+		command._doExecute();
+
+		expect( command.value ).to.be.false;
+		expect( p.getChild( 3 ).hasAttribute( attrKey ) ).to.be.false;
+		expect( p.getChild( 4 ).hasAttribute( attrKey ) ).to.be.false;
+		expect( p.getChild( 5 ).hasAttribute( attrKey ) ).to.be.false;
+	} );
+
+	it( 'should add attribute on selected nodes if execute parameter was set to true', () => {
+		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 7 ] ), new Position( root, [ 0, 10 ] ) ) );
+
+		expect( command.value ).to.be.true;
+
+		command._doExecute( true );
+
+		expect( command.value ).to.be.true;
+		expect( p.getChild( 9 ).hasAttribute( attrKey ) ).to.be.true;
+	} );
+
+	it( 'should remove attribute on selected nodes if execute parameter was set to false', () => {
+		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 5 ] ) ) );
+
+		expect( command.value ).to.be.false;
+
+		command._doExecute( false );
+
+		expect( command.value ).to.be.false;
+		expect( p.getChild( 3 ).hasAttribute( attrKey ) ).to.be.false;
+		expect( p.getChild( 4 ).hasAttribute( attrKey ) ).to.be.false;
+	} );
+
+	it( 'should change selection attribute if selection is collapsed in non-empty parent', () => {
+		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 1 ] ) ) );
+
+		expect( command.value ).to.be.false;
+
+		command._doExecute();
+
+		expect( command.value ).to.be.true;
+		expect( modelDoc.selection.hasAttribute( 'bold' ) ).to.be.true;
+
+		command._doExecute();
+
+		expect( command.value ).to.be.false;
+		expect( modelDoc.selection.hasAttribute( 'bold' ) ).to.be.false;
+	} );
+
+	it( 'should not store attribute change on selection if selection is collapsed in non-empty parent', () => {
+		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 1 ] ) ) );
+		command._doExecute();
+
+		// It should not save that bold was executed at position ( root, [ 0, 1 ] ).
+
+		// Simulate clicking right arrow key by changing selection ranges.
+		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 2 ] ) ) ] );
+
+		// Get back to previous selection.
+		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 1 ] ) ) ] );
+
+		expect( command.value ).to.be.false;
+	} );
+
+	it( 'should change selection attribute and store it if selection is collapsed in empty parent', () => {
+		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 0 ] ) ) ] );
+
+		expect( command.value ).to.be.false;
+
+		command._doExecute();
+
+		expect( command.value ).to.be.true;
+		expect( modelDoc.selection.hasAttribute( 'bold' ) ).to.be.true;
+
+		// Attribute should be stored.
+		// Simulate clicking somewhere else in the editor.
+		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 2 ] ) ) ] );
+
+		expect( command.value ).to.be.false;
+
+		// Go back to where attribute was stored.
+		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 0 ] ) ) ] );
+
+		// Attribute should be restored.
+		expect( command.value ).to.be.true;
+
+		command._doExecute();
+
+		expect( command.value ).to.be.false;
+		expect( modelDoc.selection.hasAttribute( 'bold' ) ).to.be.false;
+	} );
+
+	it( 'should not apply attribute change where it would invalid schema', () => {
+		p.insertChildren( 3, new Element( 'image' ) );
+		p.insertChildren( 12, new Element( 'image' ) );
+		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 13 ] ) ) ] );
+
+		expect( command.isEnabled ).to.be.true;
+
+		command._doExecute();
+
+		let expectedHas = [ 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0 ];
+
+		for ( let i = 0; i < expectedHas.length; i++ ) {
+			expect( p.getChild( i ).hasAttribute( attrKey ) ).to.equal( !!expectedHas[ i ] );
+		}
+	} );
+} );
+
+describe( '_checkEnabled', () => {
+	beforeEach( () => {
+		root.insertChildren( 0, [
+			new Element( 'p', [], [
+				'foo',
+				new Element( 'img' ),
+				new Element( 'img' ),
+				'bar'
+			] ),
+			new Element( 'div' ),
+			new Element( 'p' )
+		] );
+	} );
+
+	describe( 'when selection is collapsed', () => {
+		it( 'should return true if characters with the attribute can be placed at caret position', () => {
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 1 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.true;
+		} );
+
+		it( 'should return false if characters with the attribute cannot be placed at caret position', () => {
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 0 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.false;
+
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 2 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.false;
+		} );
+	} );
+
+	describe( 'when selection is not collapsed', () => {
+		it( 'should return true if there is at least one node in selection that can have the attribute', () => {
+			// Simple selection on a few characters.
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 0 ] ), new Position( root, [ 0, 3 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.true;
+
+			// Selection spans over characters but also include nodes that can't have attribute.
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 6 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.true;
+
+			// Selection on whole root content. Characters in P can have an attribute so it's valid.
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0 ] ), new Position( root, [ 3 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.true;
+
+			// Selection on empty P. P can have the attribute.
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 3 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.true;
+		} );
+
+		it( 'should return false if there are no nodes in selection that can have the attribute', () => {
+			// Selection on DIV which can't have bold text.
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 1 ] ), new Position( root, [ 2 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.false;
+
+			// Selection on two images which can't be bold.
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 3 ] ), new Position( root, [ 0, 5 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.false;
+		} );
+	} );
+
+	it( 'should return false if selection has no ranges', () => {
+		modelDoc.selection.removeAllRanges();
+		expect( command._checkEnabled() ).to.be.false;
+	} );
+} );

+ 149 - 0
packages/ckeditor5-engine/tests/command/command.js

@@ -0,0 +1,149 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Editor from '/ckeditor5/core/editor.js';
+import Command from '/ckeditor5/core/command/command.js';
+
+let element, editor, command;
+
+class CommandWithSchema extends Command {
+	constructor( editor, schemaValid ) {
+		super( editor );
+
+		this.schemaValid = schemaValid;
+	}
+
+	_checkEnabled() {
+		return this.schemaValid;
+	}
+}
+
+beforeEach( () => {
+	element = document.createElement( 'div' );
+	document.body.appendChild( element );
+
+	editor = new Editor( element );
+	command = new Command( editor );
+} );
+
+describe( 'constructor', () => {
+	it( 'should create a new command instance, that is enabled and bound to given editor', () => {
+		expect( command ).to.have.property( 'editor' ).equal( editor );
+		expect( command.isEnabled ).to.be.true;
+	} );
+
+	it( 'Command should have _doExecute method', () => {
+		expect( () => {
+			command._doExecute();
+		} ).not.to.throw;
+	} );
+
+	it( 'should add listener to its refreshState event if checkSchema method is present', () => {
+		expect( command._checkEnabled ).to.be.undefined;
+
+		command._checkEnabled = sinon.spy();
+		command.refreshState();
+
+		expect( command._checkEnabled.called ).to.be.false;
+
+		let newCommand = new CommandWithSchema( editor, true );
+		sinon.spy( newCommand, '_checkEnabled' );
+
+		newCommand.refreshState();
+
+		expect( newCommand._checkEnabled.calledOnce ).to.be.true;
+	} );
+} );
+
+describe( 'refreshState', () => {
+	it( 'should fire refreshState event', () => {
+		let spy = sinon.spy();
+
+		command.on( 'refreshState', spy );
+		command.refreshState();
+
+		expect( spy.called ).to.be.true;
+	} );
+
+	it( 'should set isEnabled property to the value passed by object-reference', () => {
+		command.on( 'refreshState', ( evt, data ) => {
+			data.isEnabled = true;
+		} );
+
+		expect( command.isEnabled ).to.be.true;
+	} );
+
+	it( 'should set isEnabled to false if _checkEnabled returns false', () => {
+		let disabledCommand = new CommandWithSchema( editor, false );
+
+		disabledCommand.refreshState();
+
+		expect( disabledCommand.isEnabled ).to.be.false;
+	} );
+} );
+
+describe( 'disable', () => {
+	it( 'should make command disabled', () => {
+		command._disable();
+
+		expect( command.isEnabled ).to.be.false;
+	} );
+
+	it( 'should not make command disabled if there is a high-priority listener forcing command to be enabled', () => {
+		command.on( 'refreshState', ( evt ) => {
+			evt.stop();
+
+			return true;
+		}, command, 1 );
+
+		command._disable();
+
+		expect( command.isEnabled ).to.be.true;
+	} );
+} );
+
+describe( 'enable', () => {
+	it( 'should make command enabled if it was previously disabled by disable()', () => {
+		command._disable();
+		command._enable();
+
+		expect( command.isEnabled ).to.be.true;
+	} );
+
+	it( 'should not make command enabled if there are other listeners disabling it', () => {
+		command._disable();
+
+		command.on( 'refreshState', ( evt, data ) => {
+			data.isEnabled = false;
+		} );
+
+		command.refreshState();
+		command._enable();
+
+		expect( command.isEnabled ).to.be.false;
+	} );
+} );
+
+describe( '_execute', () => {
+	it( 'should not execute command if it is disabled', () => {
+		command._disable();
+
+		sinon.spy( command, '_doExecute' );
+
+		command._execute();
+
+		expect( command._doExecute.called ).to.be.false;
+	} );
+
+	it( 'should execute command if it is enabled', () => {
+		sinon.spy( command, '_doExecute' );
+
+		command._execute();
+
+		expect( command._doExecute.called ).to.be.true;
+	} );
+} );

+ 24 - 0
packages/ckeditor5-engine/tests/editor/editor.js

@@ -12,7 +12,9 @@ import coreTestUtils from '/tests/core/_utils/utils.js';
 import Editor from '/ckeditor5/core/editor.js';
 import EditorConfig from '/ckeditor5/core/editorconfig.js';
 import Plugin from '/ckeditor5/core/plugin.js';
+import Command from '/ckeditor5/core/command/command.js';
 import Locale from '/ckeditor5/utils/locale.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 
 const pluginClasses = {};
 let element;
@@ -197,6 +199,28 @@ describe( 'destroy', () => {
 	} );
 } );
 
+describe( 'execute', () => {
+	it( 'should execute specified command', () => {
+		const editor = new Editor( element );
+
+		let command = new Command( editor );
+		sinon.spy( command, '_execute' );
+
+		editor.commands.set( 'command_name', command );
+		editor.execute( 'command_name' );
+
+		expect( command._execute.calledOnce ).to.be.true;
+	} );
+
+	it( 'should throw an error if specified command has not been added', () => {
+		const editor = new Editor( element );
+
+		expect( () => {
+			editor.execute( 'command' );
+		} ).to.throw( CKEditorError, /editor-command-not-found/ );
+	} );
+} );
+
 describe( 'setData', () => {
 	it( 'should set data on the editable', () => {
 		const editor = new Editor( element );

+ 42 - 12
packages/ckeditor5-engine/tests/treemodel/document/document.js

@@ -20,37 +20,37 @@ describe( 'Document', () => {
 	} );
 
 	describe( 'constructor', () => {
-		it( 'should create Document with no data, empty graveyard and empty selection', () => {
-			expect( doc ).to.have.property( 'roots' ).that.is.instanceof( Map );
-			expect( doc.roots.size ).to.equal( 1 );
+		it( 'should create Document with no data, empty graveyard and selection set to default range', () => {
+			expect( doc ).to.have.property( '_roots' ).that.is.instanceof( Map );
+			expect( doc._roots.size ).to.equal( 1 );
 			expect( doc.graveyard ).to.be.instanceof( RootElement );
 			expect( doc.graveyard.getChildCount() ).to.equal( 0 );
-			expect( doc.selection.getRanges().length ).to.equal( 0 );
+			expect( doc.selection.getRanges().length ).to.equal( 1 );
 		} );
 	} );
 
 	describe( 'createRoot', () => {
 		it( 'should create a new RootElement, add it to roots map and return it', () => {
-			let root = doc.createRoot( 'root' );
+			let root = doc.createRoot( 'root', 'root' );
 
-			expect( doc.roots.size ).to.equal( 2 );
+			expect( doc._roots.size ).to.equal( 2 );
 			expect( root ).to.be.instanceof( RootElement );
 			expect( root.getChildCount() ).to.equal( 0 );
 		} );
 
-		it( 'should throw an error when trying to create a second root with the same name', () => {
-			doc.createRoot( 'root' );
+		it( 'should throw an error when trying to create a second root with the same id', () => {
+			doc.createRoot( 'root', 'root' );
 
 			expect(
 				() => {
-					doc.createRoot( 'root' );
+					doc.createRoot( 'root', 'root' );
 				}
-			).to.throw( CKEditorError, /document-createRoot-name-exists/ );
+			).to.throw( CKEditorError, /document-createRoot-id-exists/ );
 		} );
 	} );
 
 	describe( 'getRoot', () => {
-		it( 'should return a RootElement previously created with given name', () => {
+		it( 'should return a RootElement previously created with given id', () => {
 			let newRoot = doc.createRoot( 'root' );
 			let getRoot = doc.getRoot( 'root' );
 
@@ -62,7 +62,7 @@ describe( 'Document', () => {
 				() => {
 					doc.getRoot( 'root' );
 				}
-			).to.throw( CKEditorError, /document-createRoot-root-not-exist/ );
+			).to.throw( CKEditorError, /document-getRoot-root-not-exist/ );
 		} );
 	} );
 
@@ -170,4 +170,34 @@ describe( 'Document', () => {
 			expect( order[ 6 ] ).to.equal( 'done' );
 		} );
 	} );
+
+	it( 'should update selection attributes whenever selection gets updated', () => {
+		sinon.spy( doc.selection, '_updateAttributes' );
+
+		doc.selection.fire( 'change:range' );
+
+		expect( doc.selection._updateAttributes.called ).to.be.true;
+	} );
+
+	it( 'should update selection attributes whenever changes to the document are applied', () => {
+		sinon.spy( doc.selection, '_updateAttributes' );
+
+		doc.fire( 'changesDone' );
+
+		expect( doc.selection._updateAttributes.called ).to.be.true;
+	} );
+
+	describe( '_getDefaultRoot', () => {
+		it( 'should return graveyard root if there are no other roots in the document', () => {
+			expect( doc._getDefaultRoot() ).to.equal( doc.graveyard );
+		} );
+
+		it( 'should return the first root added to the document', () => {
+			let rootA = doc.createRoot( 'rootA' );
+			doc.createRoot( 'rootB' );
+			doc.createRoot( 'rootC' );
+
+			expect( doc._getDefaultRoot() ).to.equal( rootA );
+		} );
+	} );
 } );

+ 200 - 0
packages/ckeditor5-engine/tests/treemodel/operation/rootattributeoperation.js

@@ -0,0 +1,200 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import Document from '/ckeditor5/core/treemodel/document.js';
+import RootAttributeOperation from '/ckeditor5/core/treemodel/operation/rootattributeoperation.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+
+describe( 'RootAttributeOperation', () => {
+	let doc, root;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+	} );
+
+	it( 'should have proper type', () => {
+		const op = new RootAttributeOperation(
+			root,
+			'isNew',
+			null,
+			true,
+			doc.version
+		);
+
+		expect( op.type ).to.equal( 'rootattribute' );
+	} );
+
+	it( 'should add attribute on the root element', () => {
+		doc.applyOperation(
+			new RootAttributeOperation(
+				root,
+				'isNew',
+				null,
+				true,
+				doc.version
+			)
+		);
+
+		expect( doc.version ).to.equal( 1 );
+		expect( root.hasAttribute( 'isNew' ) ).to.be.true;
+	} );
+
+	it( 'should change attribute on the root element', () => {
+		root.setAttribute( 'isNew', false );
+
+		doc.applyOperation(
+			new RootAttributeOperation(
+				root,
+				'isNew',
+				false,
+				true,
+				doc.version
+			)
+		);
+
+		expect( doc.version ).to.equal( 1 );
+		expect( root.getAttribute( 'isNew' ) ).to.be.true;
+	} );
+
+	it( 'should remove attribute from the root element', () => {
+		root.setAttribute( 'x', true );
+
+		doc.applyOperation(
+			new RootAttributeOperation(
+				root,
+				'x',
+				true,
+				null,
+				doc.version
+			)
+		);
+
+		expect( doc.version ).to.equal( 1 );
+		expect( root.hasAttribute( 'x' ) ).to.be.false;
+	} );
+
+	it( 'should create a RootAttributeOperation as a reverse', () => {
+		let operation = new RootAttributeOperation( root, 'x', 'old', 'new', doc.version );
+		let reverse = operation.getReversed();
+
+		expect( reverse ).to.be.an.instanceof( RootAttributeOperation );
+		expect( reverse.baseVersion ).to.equal( 1 );
+		expect( reverse.root ).to.equal( root );
+		expect( reverse.key ).to.equal( 'x' );
+		expect( reverse.oldValue ).to.equal( 'new' );
+		expect( reverse.newValue ).to.equal( 'old' );
+	} );
+
+	it( 'should undo adding attribute by applying reverse operation', () => {
+		let operation = new RootAttributeOperation(
+			root,
+			'isNew',
+			null,
+			true,
+			doc.version
+		);
+
+		let reverse = operation.getReversed();
+
+		doc.applyOperation( operation );
+		doc.applyOperation( reverse );
+
+		expect( doc.version ).to.equal( 2 );
+		expect( root.hasAttribute( 'x' ) ).to.be.false;
+	} );
+
+	it( 'should undo changing attribute by applying reverse operation', () => {
+		root.setAttribute( 'isNew', false );
+
+		let operation = new RootAttributeOperation(
+			root,
+			'isNew',
+			false,
+			true,
+			doc.version
+		);
+
+		let reverse = operation.getReversed();
+
+		doc.applyOperation( operation );
+		doc.applyOperation( reverse );
+
+		expect( doc.version ).to.equal( 2 );
+		expect( root.getAttribute( 'isNew' ) ).to.be.false;
+	} );
+
+	it( 'should undo remove attribute by applying reverse operation', () => {
+		root.setAttribute( 'foo', true );
+
+		let operation = new RootAttributeOperation(
+			root,
+			'foo',
+			true,
+			null,
+			doc.version
+		);
+
+		let reverse = operation.getReversed();
+
+		doc.applyOperation( operation );
+		doc.applyOperation( reverse );
+
+		expect( doc.version ).to.equal( 2 );
+		expect( root.getAttribute( 'foo' ) ).to.be.true;
+	} );
+
+	it( 'should throw an error when one try to remove and the attribute does not exists', () => {
+		expect( () => {
+			doc.applyOperation(
+				new RootAttributeOperation(
+					root,
+					'foo',
+					true,
+					null,
+					doc.version
+				)
+			);
+		} ).to.throw( CKEditorError, /operation-rootattribute-no-attr-to-remove/ );
+	} );
+
+	it( 'should throw an error when one try to insert and the attribute already exists', () => {
+		root.setAttribute( 'x', 1 );
+
+		expect( () => {
+			doc.applyOperation(
+				new RootAttributeOperation(
+					root,
+					'x',
+					null,
+					2,
+					doc.version
+				)
+			);
+		} ).to.throw( CKEditorError, /operation-rootattribute-attr-exists/ );
+	} );
+
+	it( 'should create a RootAttributeOperation with the same parameters when cloned', () => {
+		let baseVersion = doc.version;
+
+		let op = new RootAttributeOperation( root, 'foo', 'old', 'new', baseVersion );
+
+		let clone = op.clone();
+
+		// New instance rather than a pointer to the old instance.
+		expect( clone ).not.to.be.equal( op );
+
+		expect( clone ).to.be.instanceof( RootAttributeOperation );
+		expect( clone.root ).to.equal( root );
+		expect( clone.key ).to.equal( 'foo' );
+		expect( clone.oldValue ).to.equal( 'old' );
+		expect( clone.newValue ).to.equal( 'new' );
+		expect( clone.baseVersion ).to.equal( baseVersion );
+	} );
+} );

+ 230 - 0
packages/ckeditor5-engine/tests/treemodel/operation/transform.js

@@ -14,6 +14,7 @@ import Range from '/ckeditor5/core/treemodel/range.js';
 import transform from '/ckeditor5/core/treemodel/operation/transform.js';
 import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
 import AttributeOperation from '/ckeditor5/core/treemodel/operation/attributeoperation.js';
+import RootAttributeOperation from '/ckeditor5/core/treemodel/operation/rootattributeoperation.js';
 import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
 import NoOperation from '/ckeditor5/core/treemodel/operation/nooperation.js';
 
@@ -181,6 +182,23 @@ describe( 'transform', () => {
 			} );
 		} );
 
+		describe( 'by RootAttributeOperation', () => {
+			it( 'no position update', () => {
+				let transformBy = new RootAttributeOperation(
+					root,
+					'foo',
+					null,
+					'bar',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
 		describe( 'by MoveOperation', () => {
 			it( 'range and target are different than insert position: no position update', () => {
 				let transformBy = new MoveOperation(
@@ -793,6 +811,23 @@ describe( 'transform', () => {
 				} );
 			} );
 
+			describe( 'by RootAttributeOperation', () => {
+				it( 'no operation update', () => {
+					let transformBy = new RootAttributeOperation(
+						root,
+						'foo',
+						null,
+						'bar',
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+			} );
+
 			describe( 'by MoveOperation', () => {
 				it( 'range and target are different than change range: no operation update', () => {
 					let transformBy = new MoveOperation(
@@ -1297,6 +1332,167 @@ describe( 'transform', () => {
 		} );
 	} );
 
+	describe( 'RootAttributeOperation', () => {
+		let diffRoot = new RootElement( null );
+
+		beforeEach( () => {
+			expected = {
+				type: RootAttributeOperation,
+				key: 'foo',
+				oldValue: 'abc',
+				newValue: 'bar',
+				baseVersion: baseVersion + 1
+			};
+
+			op = new RootAttributeOperation( root, 'foo', 'abc', 'bar', baseVersion );
+		} );
+
+		describe( 'by InsertOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new InsertOperation(
+					new Position( root, [ 0 ] ),
+					'a',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by AttributeOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new AttributeOperation(
+					new Range(
+						new Position( root, [ 0 ] ),
+						new Position( root, [ 1 ] )
+					),
+					'foo',
+					'bar',
+					'xyz',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by RootAttributeOperation', () => {
+			it( 'changes different root: no operation update', () => {
+				let transformBy = new RootAttributeOperation(
+					diffRoot,
+					'foo',
+					'abc',
+					'xyz',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'changes different key: no operation update', () => {
+				let transformBy = new RootAttributeOperation(
+					root,
+					'abc',
+					'abc',
+					'xyz',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'sets same value for same key: convert to NoOperation', () => {
+				let transformBy = new RootAttributeOperation(
+					root,
+					'foo',
+					'abc',
+					'bar',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], {
+					type: NoOperation,
+					baseVersion: baseVersion + 1
+				} );
+			} );
+
+			it( 'sets different value for same key on same root and is important: no operation update', () => {
+				let transformBy = new RootAttributeOperation(
+					root,
+					'foo',
+					'abc',
+					'xyz',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], {
+					type: NoOperation,
+					baseVersion: baseVersion + 1
+				} );
+			} );
+
+			it( 'sets different value for same key on same root and is less important: convert to NoOperation', () => {
+				let transformBy = new RootAttributeOperation(
+					root,
+					'foo',
+					'abc',
+					'xyz',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy, true );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by MoveOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new MoveOperation(
+					new Position( root, [ 0 ] ),
+					2,
+					new Position( root, [ 1 ] ),
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by NoOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new NoOperation( baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+	} );
+
 	describe( 'MoveOperation', () => {
 		let sourcePosition, targetPosition, rangeEnd, howMany;
 
@@ -1550,6 +1746,23 @@ describe( 'transform', () => {
 			} );
 		} );
 
+		describe( 'by RootAttributeOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new RootAttributeOperation(
+					root,
+					'foo',
+					null,
+					'bar',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
 		describe( 'by MoveOperation', () => {
 			it( 'range and target different than transforming range and target: no operation update', () => {
 				let transformBy = new MoveOperation(
@@ -2378,6 +2591,23 @@ describe( 'transform', () => {
 			} );
 		} );
 
+		describe( 'by RootAttributeOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new RootAttributeOperation(
+					root,
+					'foo',
+					null,
+					'bar',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
 		describe( 'by MoveOperation', () => {
 			it( 'no operation update', () => {
 				let transformBy = new MoveOperation(

+ 211 - 0
packages/ckeditor5-engine/tests/treemodel/schema/schema.js

@@ -0,0 +1,211 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel */
+
+'use strict';
+
+import Schema from '/ckeditor5/core/treemodel/schema.js';
+import { SchemaItem as SchemaItem } from '/ckeditor5/core/treemodel/schema.js';
+import Document from '/ckeditor5/core/treemodel/document.js';
+import Element from '/ckeditor5/core/treemodel/element.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+
+let schema;
+
+beforeEach( () => {
+	schema = new Schema();
+} );
+
+describe( 'constructor', () => {
+	it( 'should register base items: inline, block, root', () => {
+		sinon.spy( Schema.prototype, 'registerItem' );
+
+		schema = new Schema();
+
+		expect( schema.registerItem.calledWithExactly( '$inline', null ) );
+		expect( schema.registerItem.calledWithExactly( '$block', null ) );
+
+		Schema.prototype.registerItem.restore();
+	} );
+
+	it( 'should allow inline in block', () => {
+		expect( schema.checkQuery( { name: '$inline', inside: [ '$block' ] } ) ).to.be.true;
+	} );
+} );
+
+describe( 'registerItem', () => {
+	it( 'should register in schema item under given name', () => {
+		schema.registerItem( 'new' );
+
+		expect( schema.hasItem( 'new' ) ).to.be.true;
+	} );
+
+	it( 'should build correct base chains', () => {
+		schema.registerItem( 'first' );
+		schema.registerItem( 'secondA', 'first' );
+		schema.registerItem( 'secondB', 'first' );
+		schema.registerItem( 'third', 'secondA' );
+
+		expect( schema._extensionChains.get( 'first' ) ).to.deep.equal( [ 'first' ] );
+		expect( schema._extensionChains.get( 'secondA' ) ).to.deep.equal( [ 'first', 'secondA' ] );
+		expect( schema._extensionChains.get( 'secondB' ) ).to.deep.equal( [ 'first', 'secondB' ] );
+		expect( schema._extensionChains.get( 'third' ) ).to.deep.equal( [ 'first', 'secondA', 'third' ] );
+	} );
+
+	it( 'should make registered item inherit allows from base item', () => {
+		schema.registerItem( 'image', '$inline' );
+
+		expect( schema.checkQuery( { name: 'image', inside: [ '$block' ] } ) ).to.be.true;
+	} );
+
+	it( 'should throw if item with given name has already been registered in schema', () => {
+		schema.registerItem( 'new' );
+
+		expect( () => {
+			schema.registerItem( 'new' );
+		} ).to.throw( CKEditorError, /schema-item-exists/ );
+	} );
+
+	it( 'should throw if base item has not been registered in schema', () => {
+		expect( () => {
+			schema.registerItem( 'new', 'old' );
+		} ).to.throw( CKEditorError, /schema-no-item/ );
+	} );
+} );
+
+describe( 'hasItem', () => {
+	it( 'should return true if given item name has been registered in schema', () => {
+		expect( schema.hasItem( '$block' ) ).to.be.true;
+	} );
+
+	it( 'should return false if given item name has not been registered in schema', () => {
+		expect( schema.hasItem( 'new' ) ).to.be.false;
+	} );
+} );
+
+describe( '_getItem', () => {
+	it( 'should return SchemaItem registered under given name', () => {
+		schema.registerItem( 'new' );
+
+		let item = schema._getItem( 'new' );
+
+		expect( item ).to.be.instanceof( SchemaItem );
+	} );
+
+	it( 'should throw if there is no item registered under given name', () => {
+		expect( () => {
+			schema._getItem( 'new' );
+		} ).to.throw( CKEditorError, /schema-no-item/ );
+	} );
+} );
+
+describe( 'allow', () => {
+	it( 'should add passed query to allowed in schema', () => {
+		schema.registerItem( 'p', '$block' );
+		schema.registerItem( 'div', '$block' );
+
+		expect( schema.checkQuery( { name: 'p', inside: [ 'div' ] } ) ).to.be.false;
+
+		schema.allow( { name: 'p', inside: 'div' } );
+
+		expect( schema.checkQuery( { name: 'p', inside: [ 'div' ] } ) ).to.be.true;
+	} );
+} );
+
+describe( 'disallow', () => {
+	it( 'should add passed query to disallowed in schema', () => {
+		schema.registerItem( 'p', '$block' );
+		schema.registerItem( 'div', '$block' );
+
+		schema.allow( { name: '$block', attribute: 'bold', inside: 'div' } );
+
+		expect( schema.checkQuery( { name: 'p', attribute: 'bold', inside: [ 'div' ] } ) ).to.be.true;
+
+		schema.disallow( { name: 'p', attribute: 'bold', inside: 'div' } );
+
+		expect( schema.checkQuery( { name: 'p', attribute: 'bold', inside: [ 'div' ] } ) ).to.be.false;
+	} );
+} );
+
+describe( 'checkAtPosition', () => {
+	let doc, root;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root', 'div' );
+
+		root.insertChildren( 0, [
+			new Element( 'div' ),
+			new Element( 'header' ),
+			new Element( 'p' )
+		] );
+
+		schema.registerItem( 'div', '$block' );
+		schema.registerItem( 'header', '$block' );
+		schema.registerItem( 'p', '$block' );
+
+		schema.allow( { name: '$block', inside: 'div' } );
+		schema.allow( { name: '$inline', attribute: 'bold', inside: '$block' } );
+
+		schema.disallow( { name: '$inline', attribute: 'bold', inside: 'header' } );
+	} );
+
+	it( 'should return true if given element is allowed by schema at given position', () => {
+		// Block should be allowed in root.
+		expect( schema.checkAtPosition( new Position( root, [ 0 ] ), '$block' ) ).to.be.true;
+
+		// P is block and block should be allowed in root.
+		expect( schema.checkAtPosition( new Position( root, [ 0 ] ), 'p' ) ).to.be.true;
+
+		// P is allowed in DIV by the set rule.
+		expect( schema.checkAtPosition( new Position( root, [ 0, 0 ] ), 'p' ) ).to.be.true;
+
+		// Inline is allowed in any block and is allowed with attribute bold.
+		// We do not check if it is allowed in header, because it is disallowed by the set rule.
+		expect( schema.checkAtPosition( new Position( root, [ 0, 0 ] ), '$inline' ) ).to.be.true;
+		expect( schema.checkAtPosition( new Position( root, [ 2, 0 ] ), '$inline' ) ).to.be.true;
+		expect( schema.checkAtPosition( new Position( root, [ 0, 0 ] ), '$inline', 'bold' ) ).to.be.true;
+		expect( schema.checkAtPosition( new Position( root, [ 2, 0 ] ), '$inline', 'bold' ) ).to.be.true;
+
+		// Header is allowed in DIV.
+		expect( schema.checkAtPosition( new Position( root, [ 0, 0 ] ), 'header' ) ).to.be.true;
+
+		// Inline is allowed in block and root is DIV, which is block.
+		expect( schema.checkAtPosition( new Position( root, [ 0 ] ), '$inline' ) ).to.be.true;
+	} );
+
+	it( 'should return false if given element is not allowed by schema at given position', () => {
+		// P with attribute is not allowed anywhere.
+		expect( schema.checkAtPosition( new Position( root, [ 0 ] ), 'p', 'bold' ) ).to.be.false;
+		expect( schema.checkAtPosition( new Position( root, [ 0, 0 ] ), 'p', 'bold' ) ).to.be.false;
+
+		// Bold text is not allowed in header
+		expect( schema.checkAtPosition( new Position( root, [ 1, 0 ] ), '$text', 'bold' ) ).to.be.false;
+	} );
+
+	it( 'should return false if given element is not registered in schema', () => {
+		expect( schema.checkAtPosition( new Position( root, [ 0 ] ), 'new' ) ).to.be.false;
+	} );
+} );
+
+describe( 'checkQuery', () => {
+	it( 'should return false if given element is not registered in schema', () => {
+		expect( schema.checkQuery( { name: 'new', inside: [ 'div', 'header' ] } ) ).to.be.false;
+	} );
+
+	it( 'should handle path given as string', () => {
+		expect( schema.checkQuery( { name: '$inline', inside: '$block $block $block' } ) ).to.be.true;
+	} );
+
+	it( 'should handle attributes', () => {
+		schema.registerItem( 'p', '$block' );
+		schema.allow( { name: 'p', inside: '$block' } );
+
+		expect( schema.checkQuery( { name: 'p', inside: '$block' } ) ).to.be.true;
+		expect( schema.checkQuery( { name: 'p', attribute: 'bold', inside: '$block' } ) ).to.be.false;
+	} );
+} );

+ 172 - 0
packages/ckeditor5-engine/tests/treemodel/schema/schemaitem.js

@@ -0,0 +1,172 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel */
+
+'use strict';
+
+import Schema from '/ckeditor5/core/treemodel/schema.js';
+import { SchemaItem as SchemaItem } from '/ckeditor5/core/treemodel/schema.js';
+
+let schema, item;
+
+beforeEach( () => {
+	schema = new Schema();
+
+	schema.registerItem( 'p', '$block' );
+	schema.registerItem( 'header', '$block' );
+	schema.registerItem( 'div', '$block' );
+	schema.registerItem( 'html', '$block' );
+	schema.registerItem( 'span', '$inline' );
+	schema.registerItem( 'image', '$inline' );
+
+	item = new SchemaItem( schema );
+} );
+
+describe( 'constructor', () => {
+	it( 'should create empty schema item', () => {
+		let item = new SchemaItem( schema );
+
+		expect( item._disallowed ).to.deep.equal( [] );
+		expect( item._allowed ).to.deep.equal( [] );
+	} );
+} );
+
+describe( 'allow', () => {
+	it( 'should add paths to the item as copies of passed array', () => {
+		let path1 = [ 'div', 'header' ];
+		let path2 = [ 'p' ];
+
+		item.allow( path1 );
+		item.allow( path2 );
+
+		let paths = item._getPaths( 'ALLOW' );
+
+		expect( paths.length ).to.equal( 2 );
+
+		expect( paths[ 0 ] ).not.to.equal( path1 );
+		expect( paths[ 1 ] ).not.to.equal( path2 );
+
+		expect( paths[ 0 ] ).to.deep.equal( [ 'div', 'header' ] );
+		expect( paths[ 1 ] ).to.deep.equal( [ 'p' ] );
+	} );
+
+	it( 'should accept paths as string with element names separated with space', () => {
+		item.allow( 'div header' );
+
+		let paths = item._getPaths( 'ALLOW' );
+
+		expect( paths[ 0 ] ).to.deep.equal( [ 'div', 'header' ] );
+	} );
+
+	it( 'should group paths by attribute', () => {
+		item.allow( 'p', 'bold' );
+		item.allow( 'div' );
+		item.allow( 'header', 'bold' );
+
+		let pathsWithNoAttribute = item._getPaths( 'ALLOW' );
+		let pathsWithBoldAttribute = item._getPaths( 'ALLOW', 'bold' );
+
+		expect( pathsWithNoAttribute.length ).to.equal( 1 );
+		expect( pathsWithNoAttribute[ 0 ] ).to.deep.equal( [ 'div' ] );
+
+		expect( pathsWithBoldAttribute.length ).to.equal( 2 );
+		expect( pathsWithBoldAttribute[ 0 ] ).to.deep.equal( [ 'p' ] );
+		expect( pathsWithBoldAttribute[ 1 ] ).to.deep.equal( [ 'header' ] );
+	} );
+} );
+
+describe( 'disallow', () => {
+	it( 'should add paths to the item as copies of passed array', () => {
+		let path1 = [ 'div', 'header' ];
+		let path2 = [ 'p' ];
+
+		item.disallow( path1 );
+		item.disallow( path2 );
+
+		let paths = item._getPaths( 'DISALLOW' );
+
+		expect( paths.length ).to.equal( 2 );
+
+		expect( paths[ 0 ] ).not.to.equal( path1 );
+		expect( paths[ 1 ] ).not.to.equal( path2 );
+
+		expect( paths[ 0 ] ).to.deep.equal( [ 'div', 'header' ] );
+		expect( paths[ 1 ] ).to.deep.equal( [ 'p' ] );
+	} );
+
+	it( 'should accept paths as string with element names separated with space', () => {
+		item.disallow( 'div header' );
+
+		let paths = item._getPaths( 'DISALLOW' );
+
+		expect( paths[ 0 ] ).to.deep.equal( [ 'div', 'header' ] );
+	} );
+
+	it( 'should group paths by attribute', () => {
+		item.disallow( 'p', 'bold' );
+		item.disallow( 'div' );
+		item.disallow( 'header', 'bold' );
+
+		let pathsWithNoAttribute = item._getPaths( 'DISALLOW' );
+		let pathsWithBoldAttribute = item._getPaths( 'DISALLOW', 'bold' );
+
+		expect( pathsWithNoAttribute.length ).to.equal( 1 );
+		expect( pathsWithNoAttribute[ 0 ] ).to.deep.equal( [ 'div' ] );
+
+		expect( pathsWithBoldAttribute.length ).to.equal( 2 );
+		expect( pathsWithBoldAttribute[ 0 ] ).to.deep.equal( [ 'p' ] );
+		expect( pathsWithBoldAttribute[ 1 ] ).to.deep.equal( [ 'header' ] );
+	} );
+} );
+
+describe( '_hasMatchingPath', () => {
+	it( 'should return true if there is at least one allowed path that matches query path', () => {
+		item.allow( 'div header' );
+		item.allow( 'image' );
+
+		expect( item._hasMatchingPath( 'ALLOW', [ 'div', 'header' ] ) ).to.be.true;
+		expect( item._hasMatchingPath( 'ALLOW', [ 'html', 'div', 'header' ] ) ).to.be.true;
+		expect( item._hasMatchingPath( 'ALLOW', [ 'div', 'header', 'span' ] ) ).to.be.true;
+		expect( item._hasMatchingPath( 'ALLOW', [ 'html', 'div', 'p', 'header', 'span' ] ) ).to.be.true;
+	} );
+
+	it( 'should return false if there are no allowed paths that match query path', () => {
+		item.allow( 'div p' );
+
+		expect( item._hasMatchingPath( 'ALLOW', [ 'p' ] ) ).to.be.false;
+		expect( item._hasMatchingPath( 'ALLOW', [ 'div' ] ) ).to.be.false;
+		expect( item._hasMatchingPath( 'ALLOW', [ 'p', 'div' ] ) ).to.be.false;
+	} );
+
+	it( 'should return true if there is at least one disallowed path that matches query path', () => {
+		item.allow( 'div header' );
+		item.disallow( 'p header' );
+
+		expect( item._hasMatchingPath( 'DISALLOW', [ 'html', 'div', 'p', 'header', 'span' ] ) ).to.be.true;
+	} );
+
+	it( 'should use only paths that are registered for given attribute', () => {
+		item.allow( 'div p' );
+		item.allow( 'div', 'bold' );
+		item.allow( 'header' );
+		item.disallow( 'header', 'bold' );
+
+		expect( item._hasMatchingPath( 'ALLOW', [ 'html', 'div', 'p' ]  ) ).to.be.true;
+		expect( item._hasMatchingPath( 'ALLOW', [ 'html', 'div' ] ) ).to.be.false;
+		expect( item._hasMatchingPath( 'ALLOW', [ 'html', 'div' ], 'bold' ) ).to.be.true;
+
+		expect( item._hasMatchingPath( 'DISALLOW', [ 'html', 'div', 'header' ] ) ).to.be.false;
+		expect( item._hasMatchingPath( 'DISALLOW', [ 'html', 'div', 'p', 'header', 'span' ], 'bold' ) ).to.be.true;
+	} );
+} );
+
+describe( 'toJSON', () => {
+	it( 'should create proper JSON string', () => {
+		let parsedItem = JSON.parse( JSON.stringify( item ) );
+
+		expect( parsedItem._schema ).to.equal( '[treeModel.Schema]' );
+	} );
+} );

+ 254 - 36
packages/ckeditor5-engine/tests/treemodel/selection.js

@@ -9,6 +9,7 @@
 
 import Document from '/ckeditor5/core/treemodel/document.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
+import Text from '/ckeditor5/core/treemodel/text.js';
 import Range from '/ckeditor5/core/treemodel/range.js';
 import Position from '/ckeditor5/core/treemodel/position.js';
 import LiveRange from '/ckeditor5/core/treemodel/liverange.js';
@@ -29,43 +30,52 @@ describe( 'Selection', () => {
 	beforeEach( () => {
 		doc = new Document();
 		root = doc.createRoot( 'root' );
-		selection = new Selection();
+		root.insertChildren( 0, [
+			new Element( 'p' ),
+			new Element( 'p' ),
+			new Element( 'p', [], 'foobar' ),
+			new Element( 'p' ),
+			new Element( 'p' ),
+			new Element( 'p' ),
+			new Element( 'p', [], 'foobar' )
+		] );
+		selection = doc.selection;
 
 		liveRange = new LiveRange( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) );
 		range = new Range( new Position( root, [ 2 ] ), new Position( root, [ 2, 2 ] ) );
 	} );
 
 	afterEach( () => {
-		selection.detach();
+		doc.destroy();
 		liveRange.detach();
 	} );
 
-	it( 'should not have any range, attributes, anchor or focus position when just created', () => {
+	it( 'should be set to default range when just created', () => {
 		let ranges = selection.getRanges();
 
-		expect( ranges.length ).to.equal( 0 );
-		expect( selection.anchor ).to.be.null;
-		expect( selection.focus ).to.be.null;
+		expect( ranges.length ).to.equal( 1 );
+		expect( selection.anchor.isEqual( new Position( root, [ 0 ] ) ) ).to.be.true;
+		expect( selection.focus.isEqual( new Position( root, [ 0 ] ) ) ).to.be.true;
 		expect( selection._attrs ).to.be.instanceof( Map );
 		expect( selection._attrs.size ).to.equal( 0 );
 	} );
 
-	it( 'should be collapsed if it has no ranges or all ranges are collapsed', () => {
-		expect( selection.isCollapsed ).to.be.true;
-
-		selection.addRange( new Range( new Position( root, [ 0 ] ), new Position( root, [ 0 ] ) ) );
+	describe( 'isCollapsed', () => {
+		it( 'should be true if all ranges are collapsed', () => {
+			selection.addRange( new Range( new Position( root, [ 0 ] ), new Position( root, [ 0 ] ) ) );
 
-		expect( selection.isCollapsed ).to.be.true;
-	} );
+			expect( selection.isCollapsed ).to.be.true;
+		} );
 
-	it( 'should not be collapsed when it has a range that is not collapsed', () => {
-		selection.addRange( liveRange );
+		it( 'should be false when it has a range that is not collapsed', () => {
+			selection.addRange( range );
 
-		expect( selection.isCollapsed ).to.be.false;
+			expect( selection.isCollapsed ).to.be.false;
 
-		selection.addRange( new Range( new Position( root, [ 0 ] ), new Position( root, [ 0 ] ) ) );
+			selection.addRange( new Range( new Position( root, [ 0 ] ), new Position( root, [ 0 ] ) ) );
 
-		expect( selection.isCollapsed ).to.be.false;
+			expect( selection.isCollapsed ).to.be.false;
+		} );
 	} );
 
 	it( 'should copy added ranges and store multiple ranges', () => {
@@ -124,16 +134,16 @@ describe( 'Selection', () => {
 		expect( ranges[ 0 ] ).to.be.instanceof( LiveRange );
 	} );
 
-	it( 'should fire update event when adding a range', () => {
+	it( 'should fire change:range event when adding a range', () => {
 		let spy = sinon.spy();
-		selection.on( 'update', spy );
+		selection.on( 'change:range', spy );
 
 		selection.addRange( range );
 
 		expect( spy.called ).to.be.true;
 	} );
 
-	it( 'should unbind all events when detached', () => {
+	it( 'should unbind all events when destroyed', () => {
 		selection.addRange( liveRange );
 		selection.addRange( range );
 
@@ -142,7 +152,7 @@ describe( 'Selection', () => {
 		sinon.spy( ranges[ 0 ], 'detach' );
 		sinon.spy( ranges[ 1 ], 'detach' );
 
-		selection.detach();
+		selection.destroy();
 
 		expect( ranges[ 0 ].detach.called ).to.be.true;
 		expect( ranges[ 1 ].detach.called ).to.be.true;
@@ -172,7 +182,7 @@ describe( 'Selection', () => {
 			selection.addRange( range );
 
 			spy = sinon.spy();
-			selection.on( 'update', spy );
+			selection.on( 'change:range', spy );
 
 			ranges = selection.getRanges();
 
@@ -187,18 +197,17 @@ describe( 'Selection', () => {
 			ranges[ 1 ].detach.restore();
 		} );
 
-		it( 'should remove all stored ranges', () => {
-			expect( selection.getRanges().length ).to.equal( 0 );
-			expect( selection.anchor ).to.be.null;
-			expect( selection.focus ).to.be.null;
-			expect( selection.isCollapsed ).to.be.true;
+		it( 'should remove all stored ranges (and reset to default range)', () => {
+			expect( selection.getRanges().length ).to.equal( 1 );
+			expect( selection.anchor.isEqual( new Position( root, [ 0 ] ) ) ).to.be.true;
+			expect( selection.focus.isEqual( new Position( root, [ 0 ] ) ) ).to.be.true;
 		} );
 
 		it( 'should fire exactly one update event', () => {
 			expect( spy.calledOnce ).to.be.true;
 		} );
 
-		it( 'should detach removed ranges', () => {
+		it( 'should detach ranges', () => {
 			expect( ranges[ 0 ].detach.called ).to.be.true;
 			expect( ranges[ 1 ].detach.called ).to.be.true;
 		} );
@@ -219,7 +228,7 @@ describe( 'Selection', () => {
 			selection.addRange( range );
 
 			spy = sinon.spy();
-			selection.on( 'update', spy );
+			selection.on( 'change:range', spy );
 
 			oldRanges = selection.getRanges();
 
@@ -266,6 +275,41 @@ describe( 'Selection', () => {
 		} );
 	} );
 
+	describe( 'getFirstRange', () => {
+		it( 'should return a range which start position is before all other ranges\' start positions', () => {
+			// This will not be the first range despite being added as first
+			selection.addRange( new Range( new Position( root, [ 4 ] ), new Position( root, [ 5 ] ) ) );
+
+			// This should be the first range.
+			selection.addRange( new Range( new Position( root, [ 1 ] ), new Position( root, [ 4 ] ) ) );
+
+			// A random range that is not first.
+			selection.addRange( new Range( new Position( root, [ 6 ] ), new Position( root, [ 7 ] ) ) );
+
+			let range = selection.getFirstRange();
+
+			expect( range.start.path ).to.deep.equal( [ 1 ] );
+			expect( range.end.path ).to.deep.equal( [ 4 ] );
+		} );
+	} );
+
+	describe( 'getFirstPosition', () => {
+		it( 'should return a position that is in selection and is before any other position from the selection', () => {
+			// This will not be a range containing the first position despite being added as first
+			selection.addRange( new Range( new Position( root, [ 4 ] ), new Position( root, [ 5 ] ) ) );
+
+			// This should be the first range.
+			selection.addRange( new Range( new Position( root, [ 1 ] ), new Position( root, [ 4 ] ) ) );
+
+			// A random range that is not first.
+			selection.addRange( new Range( new Position( root, [ 6 ] ), new Position( root, [ 7 ] ) ) );
+
+			let position = selection.getFirstPosition();
+
+			expect( position.path ).to.deep.equal( [ 1 ] );
+		} );
+	} );
+
 	// Selection uses LiveRanges so here are only simple test to see if integration is
 	// working well, without getting into complicated corner cases.
 	describe( 'after applying an operation should get updated and not fire update event', () => {
@@ -277,7 +321,7 @@ describe( 'Selection', () => {
 			selection.addRange( new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 1, 4 ] ) ) );
 
 			spy = sinon.spy();
-			selection.on( 'update', spy );
+			selection.on( 'change:range', spy );
 		} );
 
 		describe( 'InsertOperation', () => {
@@ -411,16 +455,52 @@ describe( 'Selection', () => {
 	} );
 
 	describe( 'attributes interface', () => {
+		let fullP, emptyP, rangeInFullP, rangeInEmptyP;
+
+		beforeEach( () => {
+			root.insertChildren( 0, [
+				new Element( 'p', [], 'foobar' ),
+				new Element( 'p', [], [] )
+			] );
+
+			fullP = root.getChild( 0 );
+			emptyP = root.getChild( 1 );
+
+			rangeInFullP = new Range( new Position( root, [ 0, 4 ] ), new Position( root, [ 0, 4 ] ) );
+			rangeInEmptyP = new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 0 ] ) );
+		} );
+
 		describe( 'setAttribute', () => {
 			it( 'should set given attribute on the selection', () => {
+				selection.setRanges( [ rangeInFullP ] );
+				selection.setAttribute( 'foo', 'bar' );
+
+				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
+				expect( fullP.hasAttribute( Selection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
+			} );
+
+			it( 'should store attribute if the selection is in empty node', () => {
+				selection.setRanges( [ rangeInEmptyP ] );
 				selection.setAttribute( 'foo', 'bar' );
 
 				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
+
+				expect( emptyP.getAttribute( Selection._getStoreAttributeKey( 'foo' ) ) ).to.equal( 'bar' );
+			} );
+
+			it( 'should fire change:attribute event', () => {
+				let spy = sinon.spy();
+				selection.on( 'change:attribute', spy );
+
+				selection.setAttribute( 'foo', 'bar' );
+
+				expect( spy.called ).to.be.true;
 			} );
 		} );
 
 		describe( 'hasAttribute', () => {
 			it( 'should return true if element contains attribute with given key', () => {
+				selection.setRanges( [ rangeInFullP ] );
 				selection.setAttribute( 'foo', 'bar' );
 
 				expect( selection.hasAttribute( 'foo' ) ).to.be.true;
@@ -439,6 +519,7 @@ describe( 'Selection', () => {
 
 		describe( 'getAttributes', () => {
 			it( 'should return an iterator that iterates over all attributes set on the text fragment', () => {
+				selection.setRanges( [ rangeInFullP ] );
 				selection.setAttribute( 'foo', 'bar' );
 				selection.setAttribute( 'abc', 'xyz' );
 
@@ -450,32 +531,87 @@ describe( 'Selection', () => {
 
 		describe( 'setAttributesTo', () => {
 			it( 'should remove all attributes set on element and set the given ones', () => {
+				selection.setRanges( [ rangeInFullP ] );
+				selection.setAttribute( 'abc', 'xyz' );
+				selection.setAttributesTo( { foo: 'bar' } );
+
+				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
+				expect( selection.getAttribute( 'abc' ) ).to.be.undefined;
+
+				expect( fullP.hasAttribute( Selection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
+				expect( fullP.hasAttribute( Selection._getStoreAttributeKey( 'abc' ) ) ).to.be.false;
+			} );
+
+			it( 'should remove all stored attributes and store the given ones if the selection is in empty node', () => {
+				selection.setRanges( [ rangeInEmptyP ] );
 				selection.setAttribute( 'abc', 'xyz' );
 				selection.setAttributesTo( { foo: 'bar' } );
 
 				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
 				expect( selection.getAttribute( 'abc' ) ).to.be.undefined;
+
+				expect( emptyP.getAttribute( Selection._getStoreAttributeKey( 'foo' ) ) ).to.equal( 'bar' );
+				expect( emptyP.hasAttribute( Selection._getStoreAttributeKey( 'abc' ) ) ).to.be.false;
+			} );
+
+			it( 'should fire change:attribute event', () => {
+				let spy = sinon.spy();
+				selection.on( 'change:attribute', spy );
+
+				selection.setAttributesTo( { foo: 'bar' } );
+
+				expect( spy.called ).to.be.true;
 			} );
 		} );
 
 		describe( 'removeAttribute', () => {
-			it( 'should remove attribute set on the text fragment and return true', () => {
+			it( 'should remove attribute set on the text fragment', () => {
+				selection.setRanges( [ rangeInFullP ] );
+				selection.setAttribute( 'foo', 'bar' );
+				selection.removeAttribute( 'foo' );
+
+				expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
+
+				expect( fullP.hasAttribute( Selection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
+			} );
+
+			it( 'should remove stored attribute if the selection is in empty node', () => {
+				selection.setRanges( [ rangeInEmptyP ] );
 				selection.setAttribute( 'foo', 'bar' );
-				let result = selection.removeAttribute( 'foo' );
+				selection.removeAttribute( 'foo' );
 
 				expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
-				expect( result ).to.be.true;
+
+				expect( emptyP.hasAttribute( Selection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
 			} );
 
-			it( 'should return false if text fragment does not have given attribute', () => {
-				let result = selection.removeAttribute( 'abc' );
+			it( 'should fire change:attribute event', () => {
+				let spy = sinon.spy();
+				selection.on( 'change:attribute', spy );
 
-				expect( result ).to.be.false;
+				selection.removeAttribute( 'foo' );
+
+				expect( spy.called ).to.be.true;
 			} );
 		} );
 
 		describe( 'clearAttributes', () => {
 			it( 'should remove all attributes from the element', () => {
+				selection.setRanges( [ rangeInFullP ] );
+				selection.setAttribute( 'foo', 'bar' );
+				selection.setAttribute( 'abc', 'xyz' );
+
+				selection.clearAttributes();
+
+				expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
+				expect( selection.getAttribute( 'abc' ) ).to.be.undefined;
+
+				expect( fullP.hasAttribute( Selection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
+				expect( fullP.hasAttribute( Selection._getStoreAttributeKey( 'abc' ) ) ).to.be.false;
+			} );
+
+			it( 'should remove all stored attributes if the selection is in empty node', () => {
+				selection.setRanges( [ rangeInEmptyP ] );
 				selection.setAttribute( 'foo', 'bar' );
 				selection.setAttribute( 'abc', 'xyz' );
 
@@ -483,7 +619,89 @@ describe( 'Selection', () => {
 
 				expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
 				expect( selection.getAttribute( 'abc' ) ).to.be.undefined;
+
+				expect( emptyP.hasAttribute( Selection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
+				expect( emptyP.hasAttribute( Selection._getStoreAttributeKey( 'abc' ) ) ).to.be.false;
 			} );
+
+			it( 'should fire change:attribute event', () => {
+				let spy = sinon.spy();
+				selection.on( 'change:attribute', spy );
+
+				selection.clearAttributes();
+
+				expect( spy.called ).to.be.true;
+			} );
+		} );
+	} );
+
+	describe( '_updateAttributes', () => {
+		beforeEach( () => {
+			root.insertChildren( 0, [
+				new Element( 'p', { p: true } ),
+				new Text( 'a', { a: true } ),
+				new Element( 'p', { p: true } ),
+				new Text( 'b', { b: true } ),
+				new Text( 'c', { c: true } ),
+				new Element( 'p', [], [
+					new Text( 'd', { d: true } )
+				] ),
+				new Element( 'p', { p: true } ),
+				new Text( 'e', { e: true } )
+			] );
+		} );
+
+		it( 'if selection is a range, should find first character in it and copy it\'s attributes', () => {
+			selection.setRanges( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 5 ] ) ) ] );
+
+			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'b', true ] ] );
+
+			// Step into elements when looking for first character:
+			selection.setRanges( [ new Range( new Position( root, [ 5 ] ), new Position( root, [ 7 ] ) ) ] );
+
+			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ] ] );
+		} );
+
+		it( 'if selection is collapsed it should seek a character to copy that character\'s attributes', () => {
+			// Take styles from character before selection.
+			selection.setRanges( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 2 ] ) ) ] );
+			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'a', true ] ] );
+
+			// If there are none,
+			// Take styles from character after selection.
+			selection.setRanges( [ new Range( new Position( root, [ 3 ] ), new Position( root, [ 3 ] ) ) ] );
+			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'b', true ] ] );
+
+			// If there are none,
+			// Look from the selection position to the beginning of node looking for character to take attributes from.
+			selection.setRanges( [ new Range( new Position( root, [ 6 ] ), new Position( root, [ 6 ] ) ) ] );
+			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'c', true ] ] );
+
+			// If there are none,
+			// Look from the selection position to the end of node looking for character to take attributes from.
+			selection.setRanges( [ new Range( new Position( root, [ 0 ] ), new Position( root, [ 0 ] ) ) ] );
+			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'a', true ] ] );
+
+			// If there are no characters to copy attributes from, use stored attributes.
+			selection.setRanges( [ new Range( new Position( root, [ 0, 0 ] ), new Position( root, [ 0, 0 ] ) ) ] );
+			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [] );
+		} );
+
+		it( 'should fire change:attribute event', () => {
+			let spy = sinon.spy();
+			selection.on( 'change:attribute', spy );
+
+			selection.setRanges( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 5 ] ) ) ] );
+
+			expect( spy.called ).to.be.true;
+		} );
+	} );
+
+	describe( '_getStoredAttributes', () => {
+		it( 'should return no values if there are no ranges in selection', () => {
+			let values = Array.from( selection._getStoredAttributes() );
+
+			expect( values ).to.deep.equal( [] );
 		} );
 	} );
 } );