Browse Source

Merge pull request #550 from ckeditor/t/478d

T/478d Multi-byte character support
Piotr Jasiun 9 years ago
parent
commit
d0447b06ac
26 changed files with 668 additions and 71 deletions
  1. 48 12
      packages/ckeditor5-engine/src/model/composer/modifyselection.js
  2. 44 0
      packages/ckeditor5-engine/src/model/document.js
  3. 23 4
      packages/ckeditor5-engine/src/model/textproxy.js
  4. 8 12
      packages/ckeditor5-engine/src/model/writer.js
  5. 30 2
      packages/ckeditor5-engine/src/view/textproxy.js
  6. 11 0
      packages/ckeditor5-engine/tests/_utils-tests/model.js
  7. 34 0
      packages/ckeditor5-engine/tests/_utils-tests/view.js
  8. 0 0
      packages/ckeditor5-engine/tests/conversion/buildviewconverter.js
  9. 8 0
      packages/ckeditor5-engine/tests/conversion/model-selection-to-view-converters.js
  10. 78 0
      packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js
  11. 30 13
      packages/ckeditor5-engine/tests/conversion/view-selection-to-model-converters.js
  12. 11 0
      packages/ckeditor5-engine/tests/conversion/view-to-model-converters.js
  13. 120 9
      packages/ckeditor5-engine/tests/model/composer/modifyselection.js
  14. 54 9
      packages/ckeditor5-engine/tests/model/document/document.js
  15. 13 0
      packages/ckeditor5-engine/tests/model/text.js
  16. 21 0
      packages/ckeditor5-engine/tests/model/textproxy.js
  17. 28 0
      packages/ckeditor5-engine/tests/view/domconverter/dom-to-view.js
  18. 27 0
      packages/ckeditor5-engine/tests/view/domconverter/view-to-dom.js
  19. 10 10
      packages/ckeditor5-engine/tests/view/text.js
  20. 21 0
      packages/ckeditor5-engine/tests/view/textproxy.js
  21. 8 0
      packages/ckeditor5-engine/tests/view/writer/insert.js
  22. 9 0
      packages/ckeditor5-engine/tests/view/writer/move.js
  23. 8 0
      packages/ckeditor5-engine/tests/view/writer/remove.js
  24. 8 0
      packages/ckeditor5-engine/tests/view/writer/unwrap.js
  25. 8 0
      packages/ckeditor5-engine/tests/view/writer/wrap.js
  26. 8 0
      packages/ckeditor5-engine/tests/view/writer/wrapposition.js

+ 48 - 12
packages/ckeditor5-engine/src/model/composer/modifyselection.js

@@ -6,21 +6,38 @@
 import Position from '../position.js';
 import Position from '../position.js';
 import TreeWalker from '../treewalker.js';
 import TreeWalker from '../treewalker.js';
 import Range from '../range.js';
 import Range from '../range.js';
+import { isInsideSurrogatePair, isInsideCombinedSymbol } from '../../../utils/unicode.js';
 
 
 /**
 /**
  * Modifies the selection. Currently the supported modifications are:
  * Modifies the selection. Currently the supported modifications are:
  *
  *
- * * Extending. The selection focus is moved in the specified `options.direction` with a step specified in `options.unit`
- * (defaults to `'character'`, other values are not not yet supported).
- * Note: if you extend a forward selection in a backward direction you will in fact shrink it.
+ * * Extending. The selection focus is moved in the specified `options.direction` with a step specified in `options.unit`.
+ * Possible values for `unit` are:
+ *  * `'character'` (default) - moves selection by one user-perceived character. In most cases this means moving by one
+ *  character in `String` sense. However, unicode also defines "combing marks". These are special symbols, that combines
+ *  with a symbol before it ("base character") to create one user-perceived character. For example, `q̣̇` is a normal
+ *  letter `q` with two "combining marks": upper dot (`Ux0307`) and lower dot (`Ux0323`). For most actions, i.e. extending
+ *  selection by one position, it is correct to include both "base character" and all of it's "combining marks". That is
+ *  why `'character'` value is most natural and common method of modifying selection.
+ *  * `'codePoint'` - moves selection by one unicode code point. In contrary to, `'character'` unit, this will insert
+ *  selection between "base character" and "combining mark", because "combining marks" have their own unicode code points.
+ *  However, for technical reasons, unicode code points with values above `UxFFFF` are represented in native `String` by
+ *  two characters, called "surrogate pairs". Halves of "surrogate pairs" have a meaning only when placed next to each other.
+ *  For example `𨭎` is represented in `String` by `\uD862\uDF4E`. Both `\uD862` and `\uDF4E` do not have any meaning
+ *  outside the pair (are rendered as ? when alone). Position between them would be incorrect. In this case, selection
+ *  extension will include whole "surrogate pair".
+ *
+ * **Note:** if you extend a forward selection in a backward direction you will in fact shrink it.
  *
  *
  * @method engine.model.composer.modifySelection
  * @method engine.model.composer.modifySelection
- * @param {engine.model.Selection} The selection to modify.
+ * @param {engine.model.Selection} selection The selection to modify.
  * @param {Object} [options]
  * @param {Object} [options]
  * @param {'forward'|'backward'} [options.direction='forward'] The direction in which the selection should be modified.
  * @param {'forward'|'backward'} [options.direction='forward'] The direction in which the selection should be modified.
+ * @param {'character'|'codePoint'} [options.unit='character'] The unit by which selection should be modified.
  */
  */
 export default function modifySelection( selection, options = {} ) {
 export default function modifySelection( selection, options = {} ) {
 	const isForward = options.direction != 'backward';
 	const isForward = options.direction != 'backward';
+	options.unit = options.unit ? options.unit : 'character';
 
 
 	const focus = selection.focus;
 	const focus = selection.focus;
 	const walker = new TreeWalker( {
 	const walker = new TreeWalker( {
@@ -38,21 +55,22 @@ export default function modifySelection( selection, options = {} ) {
 
 
 	let value = next.value;
 	let value = next.value;
 
 
-	// 2. Consume next character.
+	// 2. Focus is before/after text. Extending by text data.
 	if ( value.type == 'text' ) {
 	if ( value.type == 'text' ) {
-		selection.setFocus( value.nextPosition );
+		selection.setFocus( getCorrectPosition( walker, options.unit ) );
 
 
 		return;
 		return;
 	}
 	}
 
 
-	// 3. We're entering an element, so let's consume it fully.
+	// 3. Focus is before/after element. Extend by whole element.
 	if ( value.type == ( isForward ? 'elementStart' : 'elementEnd' ) ) {
 	if ( value.type == ( isForward ? 'elementStart' : 'elementEnd' ) ) {
 		selection.setFocus( value.item, isForward ? 'after' : 'before' );
 		selection.setFocus( value.item, isForward ? 'after' : 'before' );
 
 
 		return;
 		return;
 	}
 	}
 
 
-	// 4. We're leaving an element. That's more tricky.
+	// 4. If previous scenarios are false, it means that focus is at the beginning/at the end of element and by
+	// extending we are "leaving" the element. Let's see what is further.
 	next = walker.next();
 	next = walker.next();
 
 
 	// 4.1. Nothing left, so let's stay where we were.
 	// 4.1. Nothing left, so let's stay where we were.
@@ -60,20 +78,38 @@ export default function modifySelection( selection, options = {} ) {
 		return;
 		return;
 	}
 	}
 
 
-	// Replace TreeWalker step wrapper by clean step value.
 	value = next.value;
 	value = next.value;
 
 
-	// 4.2. Character found after element end. Not really a valid case in our data model, but let's
-	// do something sensible and put the selection focus before that character.
+	// 4.2. Text data found after leaving an element end. Put selection before it. This way extension will include
+	// "opening" element tag.
 	if ( value.type == 'text' ) {
 	if ( value.type == 'text' ) {
 		selection.setFocus( value.previousPosition );
 		selection.setFocus( value.previousPosition );
 	}
 	}
-	// 4.3. OK, we're entering a new element. So let's place there the focus.
+	// 4.3. An element found after leaving previous element. Put focus inside that element, at it's beginning or end.
 	else {
 	else {
 		selection.setFocus( value.item, isForward ? 0 : 'end' );
 		selection.setFocus( value.item, isForward ? 0 : 'end' );
 	}
 	}
 }
 }
 
 
+// Finds a correct position by walking in a text node and checking whether selection can be extended to given position
+// or should be extended further.
+function getCorrectPosition( walker, unit ) {
+	const textNode = walker.position.textNode;
+
+	if ( textNode ) {
+		const data = textNode.data;
+		let offset = walker.position.offset - textNode.startOffset;
+
+		while ( isInsideSurrogatePair( data, offset ) || ( unit == 'character' && isInsideCombinedSymbol( data, offset ) ) ) {
+			walker.next();
+
+			offset = walker.position.offset - textNode.startOffset;
+		}
+	}
+
+	return walker.position;
+}
+
 function getSearchRange( start, isForward ) {
 function getSearchRange( start, isForward ) {
 	const root = start.root;
 	const root = start.root;
 	const searchEnd = Position.createAt( root, isForward ? 'end' : 0 );
 	const searchEnd = Position.createAt( root, isForward ? 'end' : 0 );

+ 44 - 0
packages/ckeditor5-engine/src/model/document.js

@@ -17,6 +17,7 @@ import clone from '../../utils/lib/lodash/clone.js';
 import EmitterMixin from '../../utils/emittermixin.js';
 import EmitterMixin from '../../utils/emittermixin.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
 import mix from '../../utils/mix.js';
 import mix from '../../utils/mix.js';
+import { isInsideSurrogatePair, isInsideCombinedSymbol } from '../../utils/unicode.js';
 
 
 const graveyardName = '$graveyard';
 const graveyardName = '$graveyard';
 
 
@@ -111,6 +112,22 @@ export default class Document {
 			this.selection._updateAttributes();
 			this.selection._updateAttributes();
 		} );
 		} );
 
 
+		// Add events that will ensure selection correctness.
+		this.selection.on( 'change:range', () => {
+			for ( let range of this.selection.getRanges() ) {
+				if ( !this._validateSelectionRange( range ) ) {
+					/**
+					 * Range from document selection starts or ends at incorrect position.
+					 *
+					 * @error document-selection-wrong-position
+					 * @param {engine.model.Range} range
+					 */
+					throw new CKEditorError( 'document-selection-wrong-position: ' +
+						'Range from document selection starts or ends at incorrect position.', { range } );
+				}
+			}
+		} );
+
 		// Graveyard tree root. Document always have a graveyard root, which stores removed nodes.
 		// Graveyard tree root. Document always have a graveyard root, which stores removed nodes.
 		this.createRoot( '$root', graveyardName );
 		this.createRoot( '$root', graveyardName );
 	}
 	}
@@ -306,6 +323,18 @@ export default class Document {
 		return this.graveyard;
 		return this.graveyard;
 	}
 	}
 
 
+	/**
+	 * Checks whether given {@link engine.model.Range range} is a valid range for
+	 * {@link engine.model.Document#selection document's selection}.
+	 *
+	 * @private
+	 * @param {engine.model.Range} range Range to check.
+	 * @returns {Boolean} `true` if `range` is valid, `false` otherwise.
+	 */
+	_validateSelectionRange( range ) {
+		return validateTextNodePosition( range.start ) && validateTextNodePosition( range.end );
+	}
+
 	/**
 	/**
 	 * Fired when document changes by applying an operation.
 	 * Fired when document changes by applying an operation.
 	 *
 	 *
@@ -349,3 +378,18 @@ export default class Document {
 }
 }
 
 
 mix( Document, EmitterMixin );
 mix( Document, EmitterMixin );
+
+// Checks whether given range boundary position is valid for document selection, meaning that is not between
+// unicode surrogate pairs or base character and combining marks.
+function validateTextNodePosition( rangeBoundary ) {
+	const textNode = rangeBoundary.textNode;
+
+	if ( textNode ) {
+		const data = textNode.data;
+		const offset = rangeBoundary.offset - textNode.startOffset;
+
+		return !isInsideSurrogatePair( data, offset ) && !isInsideCombinedSymbol( data, offset );
+	}
+
+	return true;
+}

+ 23 - 4
packages/ckeditor5-engine/src/model/textproxy.js

@@ -3,6 +3,8 @@
  * For licensing, see LICENSE.md.
  * For licensing, see LICENSE.md.
  */
  */
 
 
+import CKEditorError from '../../utils/ckeditorerror.js';
+
 /**
 /**
  * `TextProxy` represents a part of {@link engine.model.Text text node}.
  * `TextProxy` represents a part of {@link engine.model.Text text node}.
  *
  *
@@ -15,11 +17,12 @@
  * on model nodes.
  * on model nodes.
  *
  *
  * **Note:** Some `TextProxy` instances may represent whole text node, not just a part of it.
  * **Note:** Some `TextProxy` instances may represent whole text node, not just a part of it.
+ * See {@link engine.model.TextProxy#isPartial}.
  *
  *
  * **Note:** `TextProxy` is not an instance of {@link engine.model.Node node}. Keep this in mind when using it as a
  * **Note:** `TextProxy` is not an instance of {@link engine.model.Node node}. Keep this in mind when using it as a
  * parameter of methods.
  * parameter of methods.
  *
  *
- * **Note:** `TextProxy` is readonly interface. If you want to perform changes on model data represented by a `TextProxy`
+ * **Note:** `TextProxy` is a readonly interface. If you want to perform changes on model data represented by a `TextProxy`
  * use {@link engine.model.writer model writer API}.
  * use {@link engine.model.writer model writer API}.
  *
  *
  * **Note:** `TextProxy` instances are created on the fly, basing on the current state of model. Because of this, it is
  * **Note:** `TextProxy` instances are created on the fly, basing on the current state of model. Because of this, it is
@@ -50,6 +53,23 @@ export default class TextProxy {
 		 */
 		 */
 		this.textNode = textNode;
 		this.textNode = textNode;
 
 
+		if ( offsetInText < 0 || offsetInText > textNode.offsetSize ) {
+			/**
+			 * Given offsetInText value is incorrect.
+			 *
+			 * @error model-textproxy-wrong-offsetintext
+			 */
+			throw new CKEditorError( 'model-textproxy-wrong-offsetintext: Given offsetInText value is incorrect.' );
+		}
+
+		if ( length < 0 || offsetInText + length > textNode.offsetSize ) {
+			/**
+			 * Given length value is incorrect.
+			 *
+			 * @error model-textproxy-wrong-length
+			 */
+			throw new CKEditorError( 'model-textproxy-wrong-length: Given length value is incorrect.' );
+		}
 		/**
 		/**
 		 * Text data represented by this text proxy.
 		 * Text data represented by this text proxy.
 		 *
 		 *
@@ -112,7 +132,7 @@ export default class TextProxy {
 	 * @type {Boolean}
 	 * @type {Boolean}
 	 */
 	 */
 	get isPartial() {
 	get isPartial() {
-		return this.offsetInText !== 0 || this.offsetSize !== this.textNode.offsetSize;
+		return this.offsetSize !== this.textNode.offsetSize;
 	}
 	}
 
 
 	/**
 	/**
@@ -150,8 +170,7 @@ export default class TextProxy {
 	 * Gets path to this text proxy.
 	 * Gets path to this text proxy.
 	 *
 	 *
 	 * @see engine.model.Node#getPath
 	 * @see engine.model.Node#getPath
-	 * @readonly
-	 * @type {Array.<Number>}
+	 * @returns {Array.<Number>}
 	 */
 	 */
 	getPath() {
 	getPath() {
 		const path = this.textNode.getPath();
 		const path = this.textNode.getPath();

+ 8 - 12
packages/ckeditor5-engine/src/model/writer.js

@@ -52,7 +52,8 @@ export function insert( position, nodes ) {
 	const parent = position.parent;
 	const parent = position.parent;
 
 
 	// Insertion might be in a text node, we should split it if that's the case.
 	// Insertion might be in a text node, we should split it if that's the case.
-	let index = _splitNodeAtPosition( position );
+	_splitNodeAtPosition( position );
+	const index = position.index;
 
 
 	// Insert nodes at given index. After splitting we have a proper index and insertion is between nodes,
 	// Insert nodes at given index. After splitting we have a proper index and insertion is between nodes,
 	// using basic `Element` API.
 	// using basic `Element` API.
@@ -86,15 +87,15 @@ export function remove( range ) {
 	const parent = range.start.parent;
 	const parent = range.start.parent;
 
 
 	// Range may be inside text nodes, we have to split them if that's the case.
 	// Range may be inside text nodes, we have to split them if that's the case.
-	const indexStart = _splitNodeAtPosition( range.start );
-	const indexEnd = _splitNodeAtPosition( range.end );
+	_splitNodeAtPosition( range.start );
+	_splitNodeAtPosition( range.end );
 
 
 	// Remove the text nodes using basic `Element` API.
 	// Remove the text nodes using basic `Element` API.
-	const removed = parent.removeChildren( indexStart, indexEnd - indexStart );
+	const removed = parent.removeChildren( range.start.index, range.end.index - range.start.index );
 
 
 	// Merge text nodes, if possible. After some nodes were removed, node before and after removed range will be
 	// Merge text nodes, if possible. After some nodes were removed, node before and after removed range will be
 	// touching at the position equal to the removed range beginning. We check merging possibility there.
 	// touching at the position equal to the removed range beginning. We check merging possibility there.
-	_mergeNodesAtIndex( parent, indexStart );
+	_mergeNodesAtIndex( parent, range.start.index );
 
 
 	return removed;
 	return removed;
 }
 }
@@ -137,7 +138,7 @@ export function move( sourceRange, targetPosition ) {
 export function setAttribute( range, key, value ) {
 export function setAttribute( range, key, value ) {
 	// Range might start or end in text nodes, so we have to split them.
 	// Range might start or end in text nodes, so we have to split them.
 	_splitNodeAtPosition( range.start );
 	_splitNodeAtPosition( range.start );
-	const indexEnd = _splitNodeAtPosition( range.end );
+	_splitNodeAtPosition( range.end );
 
 
 	// Iterate over all items in the range.
 	// Iterate over all items in the range.
 	for ( let item of range.getItems() ) {
 	for ( let item of range.getItems() ) {
@@ -157,7 +158,7 @@ export function setAttribute( range, key, value ) {
 	}
 	}
 
 
 	// Try to merge last changed node with it's previous sibling (not covered by the loop above).
 	// Try to merge last changed node with it's previous sibling (not covered by the loop above).
-	_mergeNodesAtIndex( range.end.parent, indexEnd );
+	_mergeNodesAtIndex( range.end.parent, range.end.index );
 }
 }
 
 
 /**
 /**
@@ -251,7 +252,6 @@ function _mergeNodesAtIndex( element, index ) {
  * @ignore
  * @ignore
  * @private
  * @private
  * @param {engine.model.Position} position Position at which node should be split.
  * @param {engine.model.Position} position Position at which node should be split.
- * @returns {Number} Index, in position's parent element, between split nodes.
  */
  */
 function _splitNodeAtPosition( position ) {
 function _splitNodeAtPosition( position ) {
 	const textNode = position.textNode;
 	const textNode = position.textNode;
@@ -267,11 +267,7 @@ function _splitNodeAtPosition( position ) {
 		const secondPart = new Text( textNode.data.substr( offsetDiff ), textNode.getAttributes() );
 		const secondPart = new Text( textNode.data.substr( offsetDiff ), textNode.getAttributes() );
 
 
 		element.insertChildren( index, [ firstPart, secondPart ] );
 		element.insertChildren( index, [ firstPart, secondPart ] );
-
-		return index + 1;
 	}
 	}
-
-	return element.offsetToIndex( position.offset );
 }
 }
 
 
 /**
 /**

+ 30 - 2
packages/ckeditor5-engine/src/view/textproxy.js

@@ -3,16 +3,27 @@
  * For licensing, see LICENSE.md.
  * For licensing, see LICENSE.md.
  */
  */
 
 
+import CKEditorError from '../../utils/ckeditorerror.js';
+
 /**
 /**
  * TextProxy is a wrapper for substring of {@link engine.view.Text}. Instance of this class is created by
  * TextProxy is a wrapper for substring of {@link engine.view.Text}. Instance of this class is created by
  * {@link engine.view.TreeWalker} when only a part of {@link engine.view.Text} needs to be returned.
  * {@link engine.view.TreeWalker} when only a part of {@link engine.view.Text} needs to be returned.
  *
  *
+ * `TextProxy` has an API similar to {@link engine.view.Text Text} and allows to do most of the common tasks performed
+ * on view nodes.
+ *
+ * **Note:** Some `TextProxy` instances may represent whole text node, not just a part of it.
+ * See {@link engine.view.TextProxy#isPartial}.
+ *
+ * **Note:** `TextProxy` is a readonly interface.
+ *
  * **Note:** `TextProxy` instances are created on the fly basing on the current state of parent {@link engine.view.Text}.
  * **Note:** `TextProxy` instances are created on the fly basing on the current state of parent {@link engine.view.Text}.
  * Because of this it is highly unrecommended to store references to `TextProxy instances because they might get
  * Because of this it is highly unrecommended to store references to `TextProxy instances because they might get
  * invalidated due to operations on Document. Also TextProxy is not a {@link engine.view.Node} so it can not be
  * invalidated due to operations on Document. Also TextProxy is not a {@link engine.view.Node} so it can not be
  * inserted as a child of {@link engine.view.Element}.
  * inserted as a child of {@link engine.view.Element}.
  *
  *
- * You should never create an instance of this class by your own.
+ * `TextProxy` instances are created by {@link engine.view.TreeWalker view tree walker}. You should not need to create
+ * an instance of this class by your own.
  *
  *
  * @memberOf engine.view
  * @memberOf engine.view
  */
  */
@@ -35,6 +46,23 @@ export default class TextProxy {
 		 */
 		 */
 		this.textNode = textNode;
 		this.textNode = textNode;
 
 
+		if ( offsetInText < 0 || offsetInText > textNode.data.length ) {
+			/**
+			 * Given offsetInText value is incorrect.
+			 *
+			 * @error view-textproxy-wrong-offsetintext
+			 */
+			throw new CKEditorError( 'view-textproxy-wrong-offsetintext: Given offsetInText value is incorrect.' );
+		}
+
+		if ( length < 0 || offsetInText + length > textNode.data.length ) {
+			/**
+			 * Given length value is incorrect.
+			 *
+			 * @error view-textproxy-wrong-length
+			 */
+			throw new CKEditorError( 'view-textproxy-wrong-length: Given length value is incorrect.' );
+		}
 		/**
 		/**
 		 * Text data represented by this text proxy.
 		 * Text data represented by this text proxy.
 		 *
 		 *
@@ -64,7 +92,7 @@ export default class TextProxy {
 	 * @type {Boolean}
 	 * @type {Boolean}
 	 */
 	 */
 	get isPartial() {
 	get isPartial() {
-		return this.offsetInText !== 0 || this.data.length !== this.textNode.data.length;
+		return this.data.length !== this.textNode.data.length;
 	}
 	}
 
 
 	/**
 	/**

+ 11 - 0
packages/ckeditor5-engine/tests/_utils-tests/model.js

@@ -46,6 +46,13 @@ describe( 'model test utils', () => {
 			sinon.assert.calledWithExactly( stringifySpy, root, document.selection );
 			sinon.assert.calledWithExactly( stringifySpy, root, document.selection );
 		} );
 		} );
 
 
+		it( 'should support unicode', () => {
+			root.appendChildren( 'நிலைக்கு' );
+			document.selection.addRange( Range.createFromParentsAndOffsets( root, 2, root, 6 ) );
+
+			expect( getData( document ) ).to.equal( 'நி<selection>லைக்</selection>கு' );
+		} );
+
 		it( 'should throw an error when passing invalid document', () => {
 		it( 'should throw an error when passing invalid document', () => {
 			expect( () => {
 			expect( () => {
 				getData( { invalid: 'document' } );
 				getData( { invalid: 'document' } );
@@ -127,6 +134,10 @@ describe( 'model test utils', () => {
 			test( '<b><selection backward>foo bar</b></selection>' );
 			test( '<b><selection backward>foo bar</b></selection>' );
 		} );
 		} );
 
 
+		it( 'should support unicode', () => {
+			test( 'நி<selection>லைக்</selection>கு' );
+		} );
+
 		it( 'should throw an error when passing invalid document', () => {
 		it( 'should throw an error when passing invalid document', () => {
 			expect( () => {
 			expect( () => {
 				setData( { invalid: 'document' } );
 				setData( { invalid: 'document' } );

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

@@ -154,6 +154,17 @@ describe( 'view test utils', () => {
 			expect( stringify( p, selection ) ).to.equal( '<p><b>foobar</b>[<b>bazqux</b>]</p>' );
 			expect( stringify( p, selection ) ).to.equal( '<p><b>foobar</b>[<b>bazqux</b>]</p>' );
 		} );
 		} );
 
 
+		it( 'should support unicode', () => {
+			const text = new Text( 'நிலைக்கு' );
+			const b = new Element( 'b', null, text );
+			const p = new Element( 'p', null, b );
+			const range = Range.createFromParentsAndOffsets( p, 0, text, 4 );
+			const selection = new Selection();
+			selection.addRange( range );
+
+			expect( stringify( p, selection ) ).to.equal( '<p>[<b>நிலை}க்கு</b></p>' );
+		} );
+
 		it( 'should write collapsed selection ranges inside elements', () => {
 		it( 'should write collapsed selection ranges inside elements', () => {
 			const text = new Text( 'foobar' );
 			const text = new Text( 'foobar' );
 			const p = new Element( 'p', null, text );
 			const p = new Element( 'p', null, text );
@@ -444,6 +455,29 @@ describe( 'view test utils', () => {
 			expect( ranges[ 1 ].isEqual( Range.createFromParentsAndOffsets( view, 1, view, 1 ) ) ).to.be.true;
 			expect( ranges[ 1 ].isEqual( Range.createFromParentsAndOffsets( view, 1, view, 1 ) ) ).to.be.true;
 		} );
 		} );
 
 
+		it( 'should support unicode', () => {
+			const { view, selection } = parse( '<p>[<b>நிலை}க்கு</b></p>' );
+
+			expect( view ).to.be.instanceof( Element );
+			expect( view.name ).to.equal( 'p' );
+			expect( view.childCount ).to.equal( 1 );
+
+			const b = view.getChild( 0 );
+			expect( b.name ).to.equal( 'b' );
+			expect( b.childCount ).to.equal( 1 );
+
+			const text = b.getChild( 0 );
+			expect( text.data ).to.equal( 'நிலைக்கு' );
+
+			expect( selection.rangeCount ).to.equal( 1 );
+			const range = selection.getFirstRange();
+
+			expect( range.start.parent ).to.equal( view );
+			expect( range.start.offset ).to.equal( 0 );
+			expect( range.end.parent ).to.equal( text );
+			expect( range.end.offset ).to.equal( 4 );
+		} );
+
 		it( 'should parse ranges #1', () => {
 		it( 'should parse ranges #1', () => {
 			const { view, selection } = parse( '<container:p>foo{bar]</container:p>' );
 			const { view, selection } = parse( '<container:p>foo{bar]</container:p>' );
 			expect( view.isSimilar( new ContainerElement( 'p' ) ) ).to.be.true;
 			expect( view.isSimilar( new ContainerElement( 'p' ) ) ).to.be.true;

+ 0 - 0
packages/ckeditor5-engine/tests/conversion/view-converter-builder.js → packages/ckeditor5-engine/tests/conversion/buildviewconverter.js


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

@@ -76,6 +76,14 @@ describe( 'default converters', () => {
 			);
 			);
 		} );
 		} );
 
 
+		it( 'in same container with unicode characters', () => {
+			test(
+				[ 2, 6 ],
+				'நிலைக்கு',
+				'நி{லைக்}கு'
+			);
+		} );
+
 		it( 'in same container, over attribute', () => {
 		it( 'in same container, over attribute', () => {
 			test(
 			test(
 				[ 1, 5 ],
 				[ 1, 5 ],

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

@@ -85,6 +85,15 @@ describe( 'insertText', () => {
 
 
 		expect( viewToString( viewRoot ) ).to.equal( '<div>foobar</div>' );
 		expect( viewToString( viewRoot ) ).to.equal( '<div>foobar</div>' );
 	} );
 	} );
+
+	it( 'should support unicode', () => {
+		modelRoot.appendChildren( new ModelText( 'நிலைக்கு' ) );
+		dispatcher.on( 'insert:$text', insertText() );
+
+		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div>நிலைக்கு</div>' );
+	} );
 } );
 } );
 
 
 describe( 'insertElement', () => {
 describe( 'insertElement', () => {
@@ -271,6 +280,28 @@ describe( 'wrap/unwrap', () => {
 
 
 		expect( viewToString( viewRoot ) ).to.equal( '<div><p><a href="http://foobar.com">xfoox</a></p></div>' );
 		expect( viewToString( viewRoot ) ).to.equal( '<div><p><a href="http://foobar.com">xfoox</a></p></div>' );
 	} );
 	} );
+
+	it( 'should support unicode', () => {
+		const modelElement = new ModelElement( 'paragraph', null, [ 'நி', new ModelText( 'லைக்', { bold: true } ), 'கு' ] );
+		const viewP = new ViewContainerElement( 'p' );
+		const viewB = new ViewAttributeElement( 'b' );
+
+		modelRoot.appendChildren( modelElement );
+		dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
+		dispatcher.on( 'insert:$text', insertText() );
+		dispatcher.on( 'addAttribute:bold', wrap( viewB ) );
+		dispatcher.on( 'removeAttribute:bold', unwrap( viewB ) );
+
+		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><p>நி<b>லைக்</b>கு</p></div>' );
+
+		modelWriter.removeAttribute( ModelRange.createFromElement( modelElement ), 'bold' );
+
+		dispatcher.convertAttribute( 'removeAttribute', ModelRange.createFromElement( modelElement ), 'bold', true, null );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><p>நிலைக்கு</p></div>' );
+	} );
 } );
 } );
 
 
 describe( 'move', () => {
 describe( 'move', () => {
@@ -301,6 +332,30 @@ describe( 'move', () => {
 
 
 		expect( viewToString( viewRoot ) ).to.equal( '<div><div>bar</div><div>foo<img></img>xxyy</div></div>' );
 		expect( viewToString( viewRoot ) ).to.equal( '<div><div>bar</div><div>foo<img></img>xxyy</div></div>' );
 	} );
 	} );
+
+	it( 'should support unicode', () => {
+		const modelDivA = new ModelElement( 'div', null, 'நிலைக்கு' );
+		const modelDivB = new ModelElement( 'div' );
+
+		modelRoot.appendChildren( [ modelDivA, modelDivB ] );
+		dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
+		dispatcher.on( 'insert:$text', insertText() );
+		dispatcher.on( 'move', move() );
+
+		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		modelWriter.move(
+			ModelRange.createFromParentsAndOffsets( modelDivA, 2, modelDivA, 6 ),
+			ModelPosition.createAt( modelDivB, 'end' )
+		);
+
+		dispatcher.convertMove(
+			ModelPosition.createFromParentAndOffset( modelDivA, 2 ),
+			ModelRange.createFromParentsAndOffsets( modelDivB, 0, modelDivB, 4 )
+		);
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><div>நிகு</div><div>லைக்</div></div>' );
+	} );
 } );
 } );
 
 
 describe( 'remove', () => {
 describe( 'remove', () => {
@@ -329,4 +384,27 @@ describe( 'remove', () => {
 
 
 		expect( viewToString( viewRoot ) ).to.equal( '<div><div>bar</div></div>' );
 		expect( viewToString( viewRoot ) ).to.equal( '<div><div>bar</div></div>' );
 	} );
 	} );
+
+	it( 'should support unicode', () => {
+		const modelDiv = new ModelElement( 'div', null, 'நிலைக்கு' );
+
+		modelRoot.appendChildren( modelDiv );
+		dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
+		dispatcher.on( 'insert:$text', insertText() );
+		dispatcher.on( 'remove', remove() );
+
+		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		modelWriter.move(
+			ModelRange.createFromParentsAndOffsets( modelDiv, 0, modelDiv, 6 ),
+			ModelPosition.createAt( modelDoc.graveyard, 'end' )
+		);
+
+		dispatcher.convertRemove(
+			ModelPosition.createFromParentAndOffset( modelDiv, 0 ),
+			ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 6 )
+		);
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><div>கு</div></div>' );
+	} );
 } );
 } );

+ 30 - 13
packages/ckeditor5-engine/tests/conversion/view-selection-to-model-converters.js

@@ -47,7 +47,24 @@ describe( 'convertSelectionChange', () => {
 
 
 		convertSelection( null, { newSelection: viewSelection } );
 		convertSelection( null, { newSelection: viewSelection } );
 
 
-		expect( modelGetData( model ) ).to.equals( '<paragraph>f<selection />oo</paragraph><paragraph>bar</paragraph>' );
+		expect( modelGetData( model ) ).to.equal( '<paragraph>f<selection />oo</paragraph><paragraph>bar</paragraph>' );
+	} );
+
+	it( 'should support unicode', () => {
+		modelSetData( model, '<paragraph>நிலைக்கு</paragraph>' );
+		viewSetData( view, '<p>நிலைக்கு</p>' );
+
+		// Re-bind elements that were just re-set.
+		mapper.bindElements( modelRoot.getChild( 0 ), viewRoot.getChild( 0 ) );
+
+		const viewSelection = new ViewSelection();
+		viewSelection.addRange(
+			ViewRange.createFromParentsAndOffsets( viewRoot.getChild( 0 ).getChild( 0 ), 2, viewRoot.getChild( 0 ).getChild( 0 ), 6 )
+		);
+
+		convertSelection( null, { newSelection: viewSelection } );
+
+		expect( modelGetData( model ) ).to.equal( '<paragraph>நி<selection>லைக்</selection>கு</paragraph>' );
 	} );
 	} );
 
 
 	it( 'should convert multi ranges selection', () => {
 	it( 'should convert multi ranges selection', () => {
@@ -60,24 +77,24 @@ describe( 'convertSelectionChange', () => {
 		convertSelection( null, { newSelection: viewSelection } );
 		convertSelection( null, { newSelection: viewSelection } );
 
 
 		// Too bad getData shows only the first range.
 		// Too bad getData shows only the first range.
-		expect( modelGetData( model ) ).to.equals(
+		expect( modelGetData( model ) ).to.equal(
 			'<paragraph>f<selection>o</selection>o</paragraph><paragraph>bar</paragraph>' );
 			'<paragraph>f<selection>o</selection>o</paragraph><paragraph>bar</paragraph>' );
 
 
 		const ranges = Array.from( model.selection.getRanges() );
 		const ranges = Array.from( model.selection.getRanges() );
-		expect( ranges.length ).to.equals( 2 );
+		expect( ranges.length ).to.equal( 2 );
 
 
-		expect( ranges[ 0 ].start.parent ).to.equals( modelRoot.getChild( 0 ) );
-		expect( ranges[ 0 ].start.offset ).to.equals( 1 );
-		expect( ranges[ 0 ].end.parent ).to.equals( modelRoot.getChild( 0 ) );
-		expect( ranges[ 0 ].end.offset ).to.equals( 2 );
+		expect( ranges[ 0 ].start.parent ).to.equal( modelRoot.getChild( 0 ) );
+		expect( ranges[ 0 ].start.offset ).to.equal( 1 );
+		expect( ranges[ 0 ].end.parent ).to.equal( modelRoot.getChild( 0 ) );
+		expect( ranges[ 0 ].end.offset ).to.equal( 2 );
 
 
-		expect( ranges[ 1 ].start.parent ).to.equals( modelRoot.getChild( 1 ) );
-		expect( ranges[ 1 ].start.offset ).to.equals( 1 );
-		expect( ranges[ 1 ].end.parent ).to.equals( modelRoot.getChild( 1 ) );
-		expect( ranges[ 1 ].end.offset ).to.equals( 2 );
+		expect( ranges[ 1 ].start.parent ).to.equal( modelRoot.getChild( 1 ) );
+		expect( ranges[ 1 ].start.offset ).to.equal( 1 );
+		expect( ranges[ 1 ].end.parent ).to.equal( modelRoot.getChild( 1 ) );
+		expect( ranges[ 1 ].end.offset ).to.equal( 2 );
 	} );
 	} );
 
 
-	it( 'should convert revers selection', () => {
+	it( 'should convert reverse selection', () => {
 		const viewSelection = new ViewSelection();
 		const viewSelection = new ViewSelection();
 		viewSelection.addRange( ViewRange.createFromParentsAndOffsets(
 		viewSelection.addRange( ViewRange.createFromParentsAndOffsets(
 			viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 2 ), true );
 			viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 2 ), true );
@@ -85,7 +102,7 @@ describe( 'convertSelectionChange', () => {
 		convertSelection( null, { newSelection: viewSelection } );
 		convertSelection( null, { newSelection: viewSelection } );
 
 
 		// Too bad getData shows only the first range.
 		// Too bad getData shows only the first range.
-		expect( modelGetData( model ) ).to.equals(
+		expect( modelGetData( model ) ).to.equal(
 			'<paragraph>f<selection backward>o</selection>o</paragraph><paragraph>bar</paragraph>' );
 			'<paragraph>f<selection backward>o</selection>o</paragraph><paragraph>bar</paragraph>' );
 	} );
 	} );
 } );
 } );

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

@@ -71,6 +71,17 @@ describe( 'convertText', () => {
 		expect( result ).to.be.instanceof( ModelText );
 		expect( result ).to.be.instanceof( ModelText );
 		expect( result.data ).to.equal( 'foobar' );
 		expect( result.data ).to.equal( 'foobar' );
 	} );
 	} );
+
+	it( 'should support unicode', () => {
+		const viewText = new ViewText( 'நிலைக்கு' );
+
+		dispatcher.on( 'text', convertText() );
+
+		const result = dispatcher.convert( viewText, objWithContext );
+
+		expect( result ).to.be.instanceof( ModelText );
+		expect( result.data ).to.equal( 'நிலைக்கு' );
+	} );
 } );
 } );
 
 
 describe( 'convertToModelFragment', () => {
 describe( 'convertToModelFragment', () => {

+ 120 - 9
packages/ckeditor5-engine/tests/model/composer/modifyselection.js

@@ -6,8 +6,9 @@
 /* bender-tags: model, composer */
 /* bender-tags: model, composer */
 
 
 import Document from '/ckeditor5/engine/model/document.js';
 import Document from '/ckeditor5/engine/model/document.js';
+import Selection from '/ckeditor5/engine/model/selection.js';
 import modifySelection from '/ckeditor5/engine/model/composer/modifyselection.js';
 import modifySelection from '/ckeditor5/engine/model/composer/modifyselection.js';
-import { setData, getData } from '/tests/engine/_utils/model.js';
+import { setData, stringify } from '/tests/engine/_utils/model.js';
 
 
 describe( 'Delete utils', () => {
 describe( 'Delete utils', () => {
 	let document;
 	let document;
@@ -123,6 +124,51 @@ describe( 'Delete utils', () => {
 					'<p>fo<selection backward><img></img></selection>o</p>',
 					'<p>fo<selection backward><img></img></selection>o</p>',
 					{ direction: 'backward' }
 					{ direction: 'backward' }
 				);
 				);
+
+				test(
+					'unicode support - combining mark forward',
+					'<p>foo<selection />b̂ar</p>',
+					'<p>foo<selection>b̂</selection>ar</p>'
+				);
+
+				test(
+					'unicode support - combining mark backward',
+					'<p>foob̂<selection />ar</p>',
+					'<p>foo<selection backward>b̂</selection>ar</p>',
+					{ direction: 'backward' }
+				);
+
+				test(
+					'unicode support - combining mark multiple',
+					'<p>fo<selection />o̻̐ͩbar</p>',
+					'<p>fo<selection>o̻̐ͩ</selection>bar</p>'
+				);
+
+				test(
+					'unicode support - combining mark multiple backward',
+					'<p>foo̻̐ͩ<selection />bar</p>',
+					'<p>fo<selection backward>o̻̐ͩ</selection>bar</p>',
+					{ direction: 'backward' }
+				);
+
+				test(
+					'unicode support - combining mark to the end',
+					'<p>fo<selection />o̻̐ͩ</p>',
+					'<p>fo<selection>o̻̐ͩ</selection></p>'
+				);
+
+				test(
+					'unicode support - surrogate pairs forward',
+					'<p><selection />\uD83D\uDCA9</p>',
+					'<p><selection>\uD83D\uDCA9</selection></p>'
+				);
+
+				test(
+					'unicode support - surrogate pairs backward',
+					'<p>\uD83D\uDCA9<selection /></p>',
+					'<p><selection backward>\uD83D\uDCA9</selection></p>',
+					{ direction: 'backward' }
+				);
 			} );
 			} );
 
 
 			describe( 'beyond element', () => {
 			describe( 'beyond element', () => {
@@ -218,21 +264,86 @@ describe( 'Delete utils', () => {
 			} );
 			} );
 		} );
 		} );
 
 
-		test(
-			'updates selection attributes',
-			'<p><$text bold=true>foo</$text><selection>b</selection></p>',
-			'<p><$text bold=true>foo</$text><selection bold=true />b</p>',
-			{ direction: 'backward' }
-		);
+		describe( 'unit=codePoint', () => {
+			test(
+				'does nothing on empty content',
+				'<selection />',
+				'<selection />',
+				{ unit: 'codePoint' }
+			);
+
+			test(
+				'does nothing on empty content (with empty element)',
+				'<p><selection /></p>',
+				'<p><selection /></p>',
+				{ unit: 'codePoint' }
+			);
+
+			test(
+				'extends one user-perceived character forward - latin letters',
+				'<p>f<selection />oo</p>',
+				'<p>f<selection>o</selection>o</p>',
+				{ unit: 'codePoint' }
+			);
+
+			test(
+				'extends one user-perceived character backward - latin letters',
+				'<p>fo<selection />o</p>',
+				'<p>f<selection backward>o</selection>o</p>',
+				{ unit: 'codePoint', direction: 'backward' }
+			);
+
+			test(
+				'unicode support - combining mark forward',
+				'<p>foo<selection />b̂ar</p>',
+				'<p>foo<selection>b</selection>̂ar</p>',
+				{ unit: 'codePoint' }
+			);
+
+			test(
+				'unicode support - combining mark backward',
+				'<p>foob̂<selection />ar</p>',
+				'<p>foob<selection backward>̂</selection>ar</p>',
+				{ unit: 'codePoint', direction: 'backward' }
+			);
+
+			test(
+				'unicode support - combining mark multiple',
+				'<p>fo<selection />o̻̐ͩbar</p>',
+				'<p>fo<selection>o</selection>̻̐ͩbar</p>',
+				{ unit: 'codePoint' }
+			);
+
+			test(
+				'unicode support - surrogate pairs forward',
+				'<p><selection />\uD83D\uDCA9</p>',
+				'<p><selection>\uD83D\uDCA9</selection></p>',
+				{ unit: 'codePoint' }
+			);
+
+			test(
+				'unicode support surrogate pairs backward',
+				'<p>\uD83D\uDCA9<selection /></p>',
+				'<p><selection backward>\uD83D\uDCA9</selection></p>',
+				{ unit: 'codePoint', direction: 'backward' }
+			);
+		} );
 	} );
 	} );
 
 
 	function test( title, input, output, options ) {
 	function test( title, input, output, options ) {
 		it( title, () => {
 		it( title, () => {
+			input = input.normalize();
+			output = output.normalize();
+
 			setData( document, input );
 			setData( document, input );
 
 
-			modifySelection( document.selection, options );
+			// Creating new instance of selection instead of operation on engine.model.Document#selection.
+			// Document's selection will throw errors in some test cases (which are correct cases, but only for
+			// non-document selections).
+			const testSelection = Selection.createFromSelection( document.selection );
+			modifySelection( testSelection, options );
 
 
-			expect( getData( document ) ).to.equal( output );
+			expect( stringify( document.getRoot(), testSelection ) ).to.equal( output );
 		} );
 		} );
 	}
 	}
 } );
 } );

+ 54 - 9
packages/ckeditor5-engine/tests/model/document/document.js

@@ -11,6 +11,7 @@ import Composer from '/ckeditor5/engine/model/composer/composer.js';
 import RootElement from '/ckeditor5/engine/model/rootelement.js';
 import RootElement from '/ckeditor5/engine/model/rootelement.js';
 import Batch from '/ckeditor5/engine/model/batch.js';
 import Batch from '/ckeditor5/engine/model/batch.js';
 import Delta from '/ckeditor5/engine/model/delta/delta.js';
 import Delta from '/ckeditor5/engine/model/delta/delta.js';
+import Range from '/ckeditor5/engine/model/range.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 import count from '/ckeditor5/utils/count.js';
 import count from '/ckeditor5/utils/count.js';
 import { jsonParseStringify } from '/tests/engine/model/_utils/utils.js';
 import { jsonParseStringify } from '/tests/engine/model/_utils/utils.js';
@@ -223,20 +224,64 @@ describe( 'Document', () => {
 		} );
 		} );
 	} );
 	} );
 
 
-	it( 'should update selection attributes whenever selection gets updated', () => {
-		sinon.spy( doc.selection, '_updateAttributes' );
+	describe( 'selection', () => {
+		it( 'should get updated attributes whenever selection gets updated', () => {
+			sinon.spy( doc.selection, '_updateAttributes' );
 
 
-		doc.selection.fire( 'change:range' );
+			doc.selection.fire( 'change:range' );
 
 
-		expect( doc.selection._updateAttributes.called ).to.be.true;
-	} );
+			expect( doc.selection._updateAttributes.called ).to.be.true;
+		} );
+
+		it( 'should get updated attributes whenever changes to the document are applied', () => {
+			sinon.spy( doc.selection, '_updateAttributes' );
+
+			doc.fire( 'changesDone' );
 
 
-	it( 'should update selection attributes whenever changes to the document are applied', () => {
-		sinon.spy( doc.selection, '_updateAttributes' );
+			expect( doc.selection._updateAttributes.called ).to.be.true;
+		} );
+
+		it( 'should throw if one of ranges starts or ends inside surrogate pair', () => {
+			const root = doc.createRoot();
+			root.appendChildren( '\uD83D\uDCA9' );
 
 
-		doc.fire( 'changesDone' );
+			expect( () => {
+				doc.selection.setRanges( [ Range.createFromParentsAndOffsets( root, 0, root, 1 ) ] );
+			} ).to.throw( CKEditorError, /document-selection-wrong-position/ );
+
+			expect( () => {
+				doc.selection.setRanges( [ Range.createFromParentsAndOffsets( root, 1, root, 2 ) ] );
+			} ).to.throw( CKEditorError, /document-selection-wrong-position/ );
+		} );
 
 
-		expect( doc.selection._updateAttributes.called ).to.be.true;
+		it( 'should throw if one of ranges starts or ends between base character and combining mark', () => {
+			const root = doc.createRoot();
+			root.appendChildren( 'foo̻̐ͩbar' );
+
+			expect( () => {
+				doc.selection.setRanges( [ Range.createFromParentsAndOffsets( root, 3, root, 9 ) ] );
+			} ).to.throw( CKEditorError, /document-selection-wrong-position/ );
+
+			expect( () => {
+				doc.selection.setRanges( [ Range.createFromParentsAndOffsets( root, 4, root, 9 ) ] );
+			} ).to.throw( CKEditorError, /document-selection-wrong-position/ );
+
+			expect( () => {
+				doc.selection.setRanges( [ Range.createFromParentsAndOffsets( root, 5, root, 9 ) ] );
+			} ).to.throw( CKEditorError, /document-selection-wrong-position/ );
+
+			expect( () => {
+				doc.selection.setRanges( [ Range.createFromParentsAndOffsets( root, 1, root, 3 ) ] );
+			} ).to.throw( CKEditorError, /document-selection-wrong-position/ );
+
+			expect( () => {
+				doc.selection.setRanges( [ Range.createFromParentsAndOffsets( root, 1, root, 4 ) ] );
+			} ).to.throw( CKEditorError, /document-selection-wrong-position/ );
+
+			expect( () => {
+				doc.selection.setRanges( [ Range.createFromParentsAndOffsets( root, 1, root, 5 ) ] );
+			} ).to.throw( CKEditorError, /document-selection-wrong-position/ );
+		} );
 	} );
 	} );
 
 
 	describe( '_getDefaultRoot', () => {
 	describe( '_getDefaultRoot', () => {

+ 13 - 0
packages/ckeditor5-engine/tests/model/text.js

@@ -66,5 +66,18 @@ describe( 'Text', () => {
 			expect( deserialized.data ).to.equal( 'foo' );
 			expect( deserialized.data ).to.equal( 'foo' );
 			expect( Array.from( deserialized.getAttributes() ) ).to.deep.equal( [ [ 'bold', true ] ] );
 			expect( Array.from( deserialized.getAttributes() ) ).to.deep.equal( [ [ 'bold', true ] ] );
 		} );
 		} );
+
+		it( 'should support unicode', () => {
+			let textQ = new Text( 'நி' );
+			let json = jsonParseStringify( textQ );
+
+			expect( json ).to.deep.equal( {
+				data: 'நி'
+			} );
+
+			let deserialized = Text.fromJSON( json );
+
+			expect( deserialized.data ).to.equal( 'நி' );
+		} );
 	} );
 	} );
 } );
 } );

+ 21 - 0
packages/ckeditor5-engine/tests/model/textproxy.js

@@ -9,6 +9,7 @@ import Element from '/ckeditor5/engine/model/element.js';
 import Text from '/ckeditor5/engine/model/text.js';
 import Text from '/ckeditor5/engine/model/text.js';
 import TextProxy from '/ckeditor5/engine/model/textproxy.js';
 import TextProxy from '/ckeditor5/engine/model/textproxy.js';
 import Document from '/ckeditor5/engine/model/document.js';
 import Document from '/ckeditor5/engine/model/document.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 
 
 describe( 'TextProxy', () => {
 describe( 'TextProxy', () => {
 	let doc, element, textProxy, root, textProxyNoParent, text, textNoParent;
 	let doc, element, textProxy, root, textProxyNoParent, text, textNoParent;
@@ -81,6 +82,26 @@ describe( 'TextProxy', () => {
 		expect( fullTextProxy.isPartial ).to.be.false;
 		expect( fullTextProxy.isPartial ).to.be.false;
 	} );
 	} );
 
 
+	it( 'should throw if wrong offsetInText is passed', () => {
+		expect( () => {
+			new TextProxy( text, -1, 2 );
+		} ).to.throw( CKEditorError, /model-textproxy-wrong-offsetintext/ );
+
+		expect( () => {
+			new TextProxy( text, 9, 1 );
+		} ).to.throw( CKEditorError, /model-textproxy-wrong-offsetintext/ );
+	} );
+
+	it( 'should throw if wrong length is passed', () => {
+		expect( () => {
+			new TextProxy( text, 2, -1 );
+		} ).to.throw( CKEditorError, /model-textproxy-wrong-length/ );
+
+		expect( () => {
+			new TextProxy( text, 2, 9 );
+		} ).to.throw( CKEditorError, /model-textproxy-wrong-length/ );
+	} );
+
 	describe( 'getPath', () => {
 	describe( 'getPath', () => {
 		it( 'should return path to the text proxy', () => {
 		it( 'should return path to the text proxy', () => {
 			expect( textProxy.getPath() ).to.deep.equal( [ 0, 5 ] );
 			expect( textProxy.getPath() ).to.deep.equal( [ 0, 5 ] );

+ 28 - 0
packages/ckeditor5-engine/tests/view/domconverter/dom-to-view.js

@@ -69,6 +69,21 @@ describe( 'DomConverter', () => {
 			expect( converter.getCorrespondingDom( viewP.getChild( 0 ) ) ).to.equal( domP.childNodes[ 0 ] );
 			expect( converter.getCorrespondingDom( viewP.getChild( 0 ) ) ).to.equal( domP.childNodes[ 0 ] );
 		} );
 		} );
 
 
+		it( 'should support unicode', () => {
+			const domText = document.createTextNode( 'நிலைக்கு' );
+			const domP = createElement( document, 'p', { 'class': 'foo' }, [ domText ] );
+
+			const viewP = converter.domToView( domP, { bind: true } );
+
+			expect( viewP.childCount ).to.equal( 1 );
+
+			const viewText = viewP.getChild( 0 );
+			expect( viewText.data ).to.equal( 'நிலைக்கு' );
+
+			expect( converter.getCorrespondingDom( viewP ) ).to.equal( domP );
+			expect( converter.getCorrespondingDom( viewP.getChild( 0 ) ) ).to.equal( domP.childNodes[ 0 ] );
+		} );
+
 		it( 'should create tree of view elements from DOM element without children', () => {
 		it( 'should create tree of view elements from DOM element without children', () => {
 			const domImg = createElement( document, 'img' );
 			const domImg = createElement( document, 'img' );
 			const domText = document.createTextNode( 'foo' );
 			const domText = document.createTextNode( 'foo' );
@@ -199,6 +214,19 @@ describe( 'DomConverter', () => {
 			expect( stringify( viewP, viewPosition ) ).to.equal( '<p>fo{}o<b>bar</b></p>' );
 			expect( stringify( viewP, viewPosition ) ).to.equal( '<p>fo{}o<b>bar</b></p>' );
 		} );
 		} );
 
 
+		it( 'should support unicode', () => {
+			const domText = document.createTextNode( 'நிலைக்கு' );
+			const domP = createElement( document, 'p', null, [ domText ] );
+
+			const viewP = parse( '<p>நிலைக்கு</p>' );
+
+			converter.bindElements( domP, viewP );
+
+			const viewPosition = converter.domPositionToView( domText, 4 );
+
+			expect( stringify( viewP, viewPosition ) ).to.equal( '<p>நிலை{}க்கு</p>' );
+		} );
+
 		it( 'should converter position in element', () => {
 		it( 'should converter position in element', () => {
 			const domText = document.createTextNode( 'foo' );
 			const domText = document.createTextNode( 'foo' );
 			const domB = createElement( document, 'b', null, 'bar' );
 			const domB = createElement( document, 'b', null, 'bar' );

+ 27 - 0
packages/ckeditor5-engine/tests/view/domconverter/view-to-dom.js

@@ -79,6 +79,19 @@ describe( 'DomConverter', () => {
 			expect( converter.getCorrespondingView( domP.childNodes[ 0 ] ) ).to.equal( viewP.getChild( 0 ) );
 			expect( converter.getCorrespondingView( domP.childNodes[ 0 ] ) ).to.equal( viewP.getChild( 0 ) );
 		} );
 		} );
 
 
+		it( 'should support unicode', () => {
+			const viewText = new ViewText( 'நிலைக்கு' );
+			const viewP = new ViewElement( 'p', null, viewText );
+
+			const domP = converter.viewToDom( viewP, document, { bind: true } );
+
+			expect( domP.childNodes.length ).to.equal( 1 );
+			expect( domP.childNodes[ 0 ].data ).to.equal( 'நிலைக்கு' );
+
+			expect( converter.getCorrespondingView( domP ) ).to.equal( viewP );
+			expect( converter.getCorrespondingView( domP.childNodes[ 0 ] ) ).to.equal( viewP.getChild( 0 ) );
+		} );
+
 		it( 'should create tree of DOM elements from view element without children', () => {
 		it( 'should create tree of DOM elements from view element without children', () => {
 			const viewImg = new ViewElement( 'img' );
 			const viewImg = new ViewElement( 'img' );
 			const viewText = new ViewText( 'foo' );
 			const viewText = new ViewText( 'foo' );
@@ -215,6 +228,20 @@ describe( 'DomConverter', () => {
 			expect( domPosition.parent ).to.equal( domFoo );
 			expect( domPosition.parent ).to.equal( domFoo );
 		} );
 		} );
 
 
+		it( 'should support unicode', () => {
+			const domText = document.createTextNode( 'நிலைக்கு' );
+			const domP = createElement( document, 'p', null, domText );
+			const { view: viewP, selection } = parse( '<container:p>நிலை{}க்கு</container:p>' );
+
+			converter.bindElements( domP, viewP );
+
+			const viewPosition = selection.getFirstPosition();
+			const domPosition = converter.viewPositionToDom( viewPosition );
+
+			expect( domPosition.offset ).to.equal( 4 );
+			expect( domPosition.parent ).to.equal( domText );
+		} );
+
 		it( 'should convert the position in the empty element', () => {
 		it( 'should convert the position in the empty element', () => {
 			const domP = createElement( document, 'p' );
 			const domP = createElement( document, 'p' );
 			const { view: viewP, selection } = parse( '<container:p>[]</container:p>' );
 			const { view: viewP, selection } = parse( '<container:p>[]</container:p>' );

+ 10 - 10
packages/ckeditor5-engine/tests/view/text.js

@@ -5,15 +5,15 @@
 
 
 /* bender-tags: view */
 /* bender-tags: view */
 
 
-import ViewNode from '/ckeditor5/engine/view/node.js';
-import ViewText from '/ckeditor5/engine/view/text.js';
+import Node from '/ckeditor5/engine/view/node.js';
+import Text from '/ckeditor5/engine/view/text.js';
 
 
 describe( 'Element', () => {
 describe( 'Element', () => {
 	describe( 'constructor', () => {
 	describe( 'constructor', () => {
 		it( 'should create element without attributes', () => {
 		it( 'should create element without attributes', () => {
-			const text = new ViewText( 'foo' );
+			const text = new Text( 'foo' );
 
 
-			expect( text ).to.be.an.instanceof( ViewNode );
+			expect( text ).to.be.an.instanceof( Node );
 			expect( text.data ).to.equal( 'foo' );
 			expect( text.data ).to.equal( 'foo' );
 			expect( text ).to.have.property( 'parent' ).that.is.null;
 			expect( text ).to.have.property( 'parent' ).that.is.null;
 		} );
 		} );
@@ -21,7 +21,7 @@ describe( 'Element', () => {
 
 
 	describe( 'clone', () => {
 	describe( 'clone', () => {
 		it( 'should return new text with same data', () => {
 		it( 'should return new text with same data', () => {
-			const text = new ViewText( 'foo bar' );
+			const text = new Text( 'foo bar' );
 			const clone = text.clone();
 			const clone = text.clone();
 
 
 			expect( clone ).to.not.equal( text );
 			expect( clone ).to.not.equal( text );
@@ -30,7 +30,7 @@ describe( 'Element', () => {
 	} );
 	} );
 
 
 	describe( 'isSimilar', () => {
 	describe( 'isSimilar', () => {
-		const text = new ViewText( 'foo' );
+		const text = new Text( 'foo' );
 
 
 		it( 'should return false when comparing to non-text', () => {
 		it( 'should return false when comparing to non-text', () => {
 			expect( text.isSimilar( null ) ).to.be.false;
 			expect( text.isSimilar( null ) ).to.be.false;
@@ -41,13 +41,13 @@ describe( 'Element', () => {
 			expect( text.isSimilar( text ) ).to.be.true;
 			expect( text.isSimilar( text ) ).to.be.true;
 		} );
 		} );
 
 
-		it( 'sould return true when data is the same', () => {
-			const other = new ViewText( 'foo' );
+		it( 'should return true when data is the same', () => {
+			const other = new Text( 'foo' );
 
 
 			expect( text.isSimilar( other ) ).to.be.true;
 			expect( text.isSimilar( other ) ).to.be.true;
 		} );
 		} );
 
 
-		it( 'sould return false when data is not the same', () => {
+		it( 'should return false when data is not the same', () => {
 			const other = text.clone();
 			const other = text.clone();
 			other.data = 'not-foo';
 			other.data = 'not-foo';
 
 
@@ -57,7 +57,7 @@ describe( 'Element', () => {
 
 
 	describe( 'setText', () => {
 	describe( 'setText', () => {
 		it( 'should change the text', () => {
 		it( 'should change the text', () => {
-			const text = new ViewText( 'foo' );
+			const text = new Text( 'foo' );
 			text.data = 'bar';
 			text.data = 'bar';
 
 
 			expect( text.data ).to.equal( 'bar' );
 			expect( text.data ).to.equal( 'bar' );

+ 21 - 0
packages/ckeditor5-engine/tests/view/textproxy.js

@@ -10,6 +10,7 @@ import Text from '/ckeditor5/engine/view/text.js';
 import ContainerElement from '/ckeditor5/engine/view/containerelement.js';
 import ContainerElement from '/ckeditor5/engine/view/containerelement.js';
 import DocumentFragment from '/ckeditor5/engine/view/documentfragment.js';
 import DocumentFragment from '/ckeditor5/engine/view/documentfragment.js';
 import RootEditableElement from '/ckeditor5/engine/view/rooteditableelement.js';
 import RootEditableElement from '/ckeditor5/engine/view/rooteditableelement.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 
 
 import createDocumentMock from '/tests/engine/view/_utils/createdocumentmock.js';
 import createDocumentMock from '/tests/engine/view/_utils/createdocumentmock.js';
 
 
@@ -40,6 +41,26 @@ describe( 'TextProxy', () => {
 			expect( startTextProxy.isPartial ).to.be.true;
 			expect( startTextProxy.isPartial ).to.be.true;
 			expect( fullTextProxy.isPartial ).to.be.false;
 			expect( fullTextProxy.isPartial ).to.be.false;
 		} );
 		} );
+
+		it( 'should throw if wrong offsetInText is passed', () => {
+			expect( () => {
+				new TextProxy( text, -1, 2 );
+			} ).to.throw( CKEditorError, /view-textproxy-wrong-offsetintext/ );
+
+			expect( () => {
+				new TextProxy( text, 9, 1 );
+			} ).to.throw( CKEditorError, /view-textproxy-wrong-offsetintext/ );
+		} );
+
+		it( 'should throw if wrong length is passed', () => {
+			expect( () => {
+				new TextProxy( text, 2, -1 );
+			} ).to.throw( CKEditorError, /view-textproxy-wrong-length/ );
+
+			expect( () => {
+				new TextProxy( text, 2, 9 );
+			} ).to.throw( CKEditorError, /view-textproxy-wrong-length/ );
+		} );
 	} );
 	} );
 
 
 	describe( 'getDocument', () => {
 	describe( 'getDocument', () => {

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

@@ -138,6 +138,14 @@ describe( 'writer', () => {
 			);
 			);
 		} );
 		} );
 
 
+		it( 'should insert unicode text into unicode text', () => {
+			test(
+				'நி{}க்கு',
+				[ 'லை' ],
+				'நி{லை}க்கு'
+			);
+		} );
+
 		it( 'should throw when inserting Element', () => {
 		it( 'should throw when inserting Element', () => {
 			const element = new Element( 'b' );
 			const element = new Element( 'b' );
 			const container = new ContainerElement( 'p' );
 			const container = new ContainerElement( 'p' );

+ 9 - 0
packages/ckeditor5-engine/tests/view/writer/move.js

@@ -55,6 +55,15 @@ describe( 'writer', () => {
 			);
 			);
 		} );
 		} );
 
 
+		it( 'should support unicode', () => {
+			test(
+				'<container:p>நி{லை}க்கு</container:p>',
+				'<container:p>நி{}கு</container:p>',
+				'<container:p>நிக்கு</container:p>',
+				'<container:p>நி{லை}கு</container:p>'
+			);
+		} );
+
 		it( 'should move parts of nodes', () => {
 		it( 'should move parts of nodes', () => {
 			test(
 			test(
 				'<container:p>f{oo<attribute:b:10>ba}r</attribute:b:10></container:p>',
 				'<container:p>f{oo<attribute:b:10>ba}r</attribute:b:10></container:p>',

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

@@ -79,6 +79,14 @@ describe( 'writer', () => {
 			);
 			);
 		} );
 		} );
 
 
+		it( 'should support unicode', () => {
+			test(
+				'<container:p>நி{லை<attribute:b:10>க்}கு</attribute:b:10></container:p>',
+				'<container:p>நி[]<attribute:b:10>கு</attribute:b:10></container:p>',
+				'லை<attribute:b:10>க்</attribute:b:10>'
+			);
+		} );
+
 		it( 'should merge after removing #1', () => {
 		it( 'should merge after removing #1', () => {
 			test(
 			test(
 				'<container:p><attribute:b:1>foo</attribute:b:1>[bar]<attribute:b:1>bazqux</attribute:b:1></container:p>',
 				'<container:p><attribute:b:1>foo</attribute:b:1>[bar]<attribute:b:1>bazqux</attribute:b:1></container:p>',

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

@@ -121,6 +121,14 @@ describe( 'writer', () => {
 			);
 			);
 		} );
 		} );
 
 
+		it( 'should support unicode', () => {
+			test(
+				'<container:p>[நிலை<attribute:b:1>க்}கு</attribute:b:1>',
+				'<attribute:b:1></attribute:b:1>',
+				'<container:p>[நிலைக்]<attribute:b:1>கு</attribute:b:1></container:p>'
+			);
+		} );
+
 		it( 'should unwrap nested attributes', () => {
 		it( 'should unwrap nested attributes', () => {
 			test(
 			test(
 				'<container:p>[<attribute:u:1><attribute:b:1>foobar</attribute:b:1></attribute:u:1>]</container:p>',
 				'<container:p>[<attribute:u:1><attribute:b:1>foobar</attribute:b:1></attribute:u:1>]</container:p>',

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

@@ -107,6 +107,14 @@ describe( 'writer', () => {
 			);
 			);
 		} );
 		} );
 
 
+		it( 'should support unicode', () => {
+			test(
+				'<container:p>நி{லை}க்கு</container:p>',
+				'<attribute:b:1></attribute:b:1>',
+				'<container:p>நி[<attribute:b:1>லை</attribute:b:1>]க்கு</container:p>'
+			);
+		} );
+
 		it( 'wraps part of a single text node #3', () => {
 		it( 'wraps part of a single text node #3', () => {
 			test(
 			test(
 				'<container:p>foo{bar}</container:p>',
 				'<container:p>foo{bar}</container:p>',

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

@@ -54,6 +54,14 @@ describe( 'wrapPosition', () => {
 		);
 		);
 	} );
 	} );
 
 
+	it( 'should support unicode', () => {
+		test(
+			'<container:p>நிலை{}க்கு</container:p>',
+			'<attribute:b:1></attribute:b:1>',
+			'<container:p>நிலை<attribute:b:1>[]</attribute:b:1>க்கு</container:p>'
+		);
+	} );
+
 	it( 'should wrap position inside document fragment', () => {
 	it( 'should wrap position inside document fragment', () => {
 		test(
 		test(
 			'<attribute:b:1>foo</attribute:b:1>[]<attribute:b:3>bar</attribute:b:3>',
 			'<attribute:b:1>foo</attribute:b:1>[]<attribute:b:3>bar</attribute:b:3>',