Kaynağa Gözat

Changed: treeModel.NodeList nodes compression.

Szymon Cofalik 10 yıl önce
ebeveyn
işleme
a7f6578353

+ 217 - 30
packages/ckeditor5-engine/src/treemodel/nodelist.js

@@ -5,9 +5,8 @@
 
 'use strict';
 
-import Character from './character.js';
+import TextNode from './textnode.js';
 import Text from './text.js';
-import Node from './node.js';
 import utils from '../utils.js';
 
 /**
@@ -16,13 +15,14 @@ import utils from '../utils.js';
  *
  * Thanks to the constructor, which accepts various arguments, this class lets you easily create desired list of nodes.
  *
- * It also may internally compress nodes.
+ * Parameters passed to constructor are converted and internally kept as an array of {@link treeModel.Node}
+ * and {@link treeModel.Text} instances.
  *
  * @class treeModel.NodeList
  */
 export default class NodeList {
 	/**
-	 * Constructor let you create a list of nodes in many ways. See examples:
+	 * Constructor lets you create a list of nodes in many ways. See examples:
 	 *
 	 *		let nodeList = new NodeList( [ new Element( p1 ), new Element( p1 ) ] );
 	 *		nodeList.length; // 2
@@ -47,7 +47,7 @@ export default class NodeList {
 	 *		nodeListA === nodeListB // true
 	 *		nodeListB.length // 3
 	 *
-	 * @param {treeModel.Node|treeModel.Text|treeModel.NodeList|String|Iterable} nodes List of nodes.
+	 * @param {treeModel.Node|treeModel.Text|treeModel.TextNode|String|treeModel.NodeList|Iterable} nodes List of nodes.
 	 * @constructor
 	 */
 	constructor( nodes ) {
@@ -59,34 +59,59 @@ export default class NodeList {
 		/**
 		 * Internal array to store nodes.
 		 *
-		 * @private
+		 * @protected
 		 * @property {Array}
 		 */
 		this._nodes = [];
 
-		if ( nodes ) {
-			let node, character;
+		/**
+		 * Internal array where each index is mapped to correct node from `_nodes` array. This is introduced
+		 * to easily refer `_nodes` by index, since some of elements in `_nodes` may contain multiple characters,
+		 * which occupy multiple slots in `_indexMap`.
+		 *
+		 * @private
+		 * @property {Array}
+		 */
+		this._indexMap = [];
 
-			if ( !utils.isIterable( nodes ) ) {
+		if ( nodes ) {
+			if ( typeof nodes == 'string' || !utils.isIterable( nodes ) ) {
 				nodes = [ nodes ];
 			}
 
-			for ( node of nodes ) {
-				// Node.
-				if ( node instanceof Node ) {
-					this._nodes.push( node );
+			for ( let node of nodes ) {
+				let indexInNodes = this._nodes.length;
+				let mergedWithPrev = false;
+				let length = 1;
+
+				if ( node instanceof TextNode ) {
+					node = new Text( node.text, node.attrs );
+				} else if ( typeof node == 'string' ) {
+					node = new Text( node, [] );
 				}
-				// Text.
-				else if ( node instanceof Text ) {
-					for ( character of node.text ) {
-						this._nodes.push( new Character( character, node.attrs ) );
+
+				if ( node instanceof Text ) {
+					length = node.text.length;
+
+					let prev = this._nodes[ this._nodes.length - 1 ];
+
+					if ( prev instanceof Text && prev.attrs.isEqual( node.attrs ) ) {
+						// If previously added text has same attributes, merge this text with it.
+						prev.text += node.text;
+						mergedWithPrev = true;
+						indexInNodes--;
+					} else if ( node.text.length === 0 ) {
+						// If this is an empty text just omit it.
+						continue;
 					}
 				}
-				// String.
-				else {
-					for ( character of node ) {
-						this._nodes.push( new Character( character ) );
-					}
+
+				if ( !mergedWithPrev ) {
+					this._nodes.push( node );
+				}
+
+				for ( let i = 0; i < length; i++ ) {
+					this._indexMap.push( indexInNodes );
 				}
 			}
 		}
@@ -99,14 +124,21 @@ export default class NodeList {
 	 * @property {Number} length
 	 */
 	get length() {
-		return this._nodes.length;
+		return this._indexMap.length;
 	}
 
 	/**
 	 * Node list iterator.
 	 */
 	[ Symbol.iterator ]() {
-		return this._nodes[ Symbol.iterator ]();
+		let i = 0;
+
+		return {
+			next: () => ( {
+				done: i == this.length,
+				value: this.get( i++ )
+			} )
+		};
 	}
 
 	/**
@@ -116,17 +148,32 @@ export default class NodeList {
 	 * @returns {treeModel.Node} Node at given index.
 	 */
 	get( index ) {
-		return this._nodes[ index ];
+		let realIndex = this._indexMap[ index ];
+		let node = this._nodes[ realIndex ];
+
+		if ( node instanceof Text ) {
+			return node.getTextNode( this._getCharIndex( index ), 1 );
+		} else {
+			return node;
+		}
 	}
 
 	/**
-	 * Search for the node in the node list.
+	 * Search for the element in the node list.
 	 *
 	 * @param {treeModel.Node} node Node to find.
-	 * @returns {Number} Position of the node in the list.
+	 * @returns {Number} Position of the element in the list or -1 if not found.
 	 */
 	indexOf( node ) {
-		return this._nodes.indexOf( node );
+		if ( node instanceof TextNode ) {
+			let baseIndex = this.indexOf( node._textItem );
+
+			return baseIndex == -1 ? -1 : baseIndex + node._start;
+		}
+
+		let realIndex = this._nodes.indexOf( node );
+
+		return this._indexMap.indexOf( realIndex );
 	}
 
 	/**
@@ -136,7 +183,36 @@ export default class NodeList {
 	 * @param {treeModel.NodeList} nodeList List of nodes to insert.
 	 */
 	insert( index, nodeList ) {
-		this._nodes.splice.apply( this._nodes, [ index, 0 ].concat( nodeList._nodes ) );
+		if ( this._nodes.length === 0 ) {
+			this._nodes = nodeList._nodes.slice();
+			this._indexMap = nodeList._indexMap.slice();
+
+			return;
+		}
+
+		// If we are inserting into a text, splitting may be needed.
+		this._splitNodeAt( index );
+
+		// If `index` is too high to be found in `_indexMap` it means that we insert at the end of node list.
+		let realIndex = index >= this._indexMap.length ? this._nodes.length : this._indexMap[ index ];
+
+		// Splice arrays from inserted nodeList into this nodeList.
+		this._indexMap.splice.apply( this._indexMap, [ index, 0 ].concat( nodeList._indexMap ) );
+		this._nodes.splice.apply( this._nodes, [ realIndex, 0 ].concat( nodeList._nodes ) );
+
+		// Fix indexes in index map.
+		// From the beginning of spliced-in array to the end of spliced-in array.
+		for ( let i = index; i < index + nodeList._indexMap.length; i++ ) {
+			this._indexMap[ i ] += realIndex;
+		}
+
+		// From the end of spliced-in array to the end of original array.
+		for ( let i = index + nodeList._indexMap.length; i < this._indexMap.length; i++ ) {
+			this._indexMap[ i ] += nodeList._nodes.length;
+		}
+
+		this._mergeNodeAt( index );
+		this._mergeNodeAt( index + nodeList.length );
 	}
 
 	/**
@@ -147,6 +223,117 @@ export default class NodeList {
 	 * @returns {treeModel.NodeList} List of removed nodes.
 	 */
 	remove( index, number ) {
-		return new NodeList( this._nodes.splice( index, number ) );
+		if ( this._nodes.length === 0 ) {
+			return new NodeList();
+		}
+
+		// Removed "range" may start in text node or end in text node. Some splitting may be needed.
+		this._splitNodeAt( index );
+		this._splitNodeAt( index + number );
+
+		// If given index is too high to be found in `_indexMap` it means that we remove to the end of node list.
+		let realIndexEnd = ( index + number ) >= this._indexMap.length ? this._nodes.length : this._indexMap[ index + number ];
+		let realIndexStart = this._indexMap[ index ];
+		let removed = this._nodes.splice( realIndexStart, realIndexEnd - realIndexStart );
+
+		this._indexMap.splice( index, number );
+
+		for ( let i = index; i < this._indexMap.length ; i++ ) {
+			this._indexMap[ i ] -= removed.length;
+		}
+
+		this._mergeNodeAt( index );
+
+		return new NodeList( removed );
+	}
+
+	/**
+	 * Checks whether given index is inside a text object and if so, splits that text node. This method should be used
+	 * to split text objects whenever there are some changes made on a part of text object (i.e. removing part of text,
+	 * inserting between text object, changing attributes of part of a text object).
+	 *
+	 * @param {Number} index Index in the node list at which node should be broken.
+	 * @protected
+	 */
+	_splitNodeAt( index ) {
+		if ( this._indexMap[ index ] != this._indexMap[ index - 1 ] || this._indexMap.length === 0 ) {
+			// Node before and node after splitting point are already different.
+			// Or the node list is empty.
+			// No splitting is needed.
+			return;
+		}
+
+		let realIndex = this._indexMap[ index ];
+		let node = this._nodes[ realIndex ];
+
+		// Get position in the text node where the text should be split.
+		let charIndex = this._getCharIndex( index );
+
+		// Get text part before and after split point.
+		let textBefore = node.text.substr( 0, charIndex );
+		let textAfter = node.text.substr( charIndex );
+
+		// "Remove" part after split point from current text node.
+		node.text = textBefore;
+
+		// Create a new text node with the "removed" part and splice it after original node.
+		let newText = new Text( textAfter, node.attrs );
+		newText.parent = node.parent;
+		this._nodes.splice.call( this._nodes, realIndex + 1, 0, newText );
+
+		// We added new element in the middle of _nodes what invalidated _indexMap. We have to fix it.
+		for ( let i = index; i < this._indexMap.length; i++ ) {
+			this._indexMap[ i ]++;
+		}
+	}
+
+	/**
+	 * Checks whether given index is between two text nodes that have same attributes and if so, merges them
+	 * together into one node. Used to compress characters into large text objects and use less memory. This method
+	 * should be used whenever there are some changed done to the node list to check whether it is possible to merge
+	 * text objects.
+	 *
+	 * @param {Number} index Index in the node list at which node should be merged.
+	 * @protected
+	 */
+	_mergeNodeAt( index ) {
+		if ( this._indexMap[ index ] == this._indexMap[ index - 1 ] || this._indexMap.length === 0 ) {
+			// Node before and node after splitting point are already same.
+			// Or the node list is empty.
+			// No splitting is needed.
+			return;
+		}
+
+		// Get the node before and after given index.
+		let realIndexBefore = this._indexMap[ index - 1 ];
+		let realIndexAfter = this._indexMap[ index ];
+
+		let nodeBefore = this._nodes[ realIndexBefore ];
+		let nodeAfter = this._nodes[ realIndexAfter ];
+
+		// Check if both of those nodes are text objects with same attributes.
+		if ( nodeBefore instanceof Text && nodeAfter instanceof Text && nodeBefore.attrs.isEqual( nodeAfter.attrs ) ) {
+			// Append text of text node after index to the before one.
+			nodeBefore.text += nodeAfter.text;
+
+			// Remove text node after index.
+			this._nodes.splice( realIndexAfter, 1 );
+
+			for ( let i = index; i < this._indexMap.length ; i++ ) {
+				this._indexMap[ i ]--;
+			}
+		}
+	}
+
+	/**
+	 * Helper function that takes an index in a node list that is inside a text node and returns the offset of that
+	 * index from the beginning of that text node. If index
+	 *
+	 * @param index
+	 * @returns {Number} Offset of given index from the beginning of the text node.
+	 * @private
+	 */
+	_getCharIndex( index ) {
+		return index - this._indexMap.indexOf( this._indexMap[ index ] );
 	}
 }

+ 163 - 3
packages/ckeditor5-engine/tests/treemodel/nodelist.js

@@ -14,10 +14,11 @@ import Attribute from '/ckeditor5/core/treemodel/attribute.js';
 
 describe( 'NodeList', () => {
 	describe( 'constructor', () => {
-		it( 'should change array of strings into a set of nodes', () => {
-			let nodeList = new NodeList( [ 'foo', new Character( 'x' ), 'bar' ] );
+		it( 'should change array of characters into a set of nodes', () => {
+			let textFoo = new Text( 'foo' );
+			let nodeList = new NodeList( [ 'foo', new Text( 'x' ), 'bar', textFoo.getTextNode() ] );
 
-			expect( nodeList.length ).to.equal( 7 );
+			expect( nodeList.length ).to.equal( 10 );
 			expect( nodeList.get( 0 ).text ).to.equal( 'f' );
 			expect( nodeList.get( 1 ).text ).to.equal( 'o' );
 			expect( nodeList.get( 2 ).text ).to.equal( 'o' );
@@ -25,6 +26,28 @@ describe( 'NodeList', () => {
 			expect( nodeList.get( 4 ).text ).to.equal( 'b' );
 			expect( nodeList.get( 5 ).text ).to.equal( 'a' );
 			expect( nodeList.get( 6 ).text ).to.equal( 'r' );
+			expect( nodeList.get( 7 ).text ).to.equal( 'f' );
+			expect( nodeList.get( 8 ).text ).to.equal( 'o' );
+			expect( nodeList.get( 9 ).text ).to.equal( 'o' );
+		} );
+
+		it( 'should omit empty strings / texts', () => {
+			let nodeList = new NodeList( [ 'fo', '', 'ob', new Text( '', [ new Attribute( 'foo', true ) ] ), 'ar' ] );
+
+			expect( nodeList.length ).to.equal( 6 );
+			expect( nodeList.get( 0 ).text ).to.equal( 'f' );
+			expect( nodeList.get( 1 ).text ).to.equal( 'o' );
+			expect( nodeList.get( 2 ).text ).to.equal( 'o' );
+			expect( nodeList.get( 3 ).text ).to.equal( 'b' );
+			expect( nodeList.get( 4 ).text ).to.equal( 'a' );
+			expect( nodeList.get( 5 ).text ).to.equal( 'r' );
+
+			expect( nodeList.get( 0 ).attrs.size ).to.equal( 0 );
+			expect( nodeList.get( 1 ).attrs.size ).to.equal( 0 );
+			expect( nodeList.get( 2 ).attrs.size ).to.equal( 0 );
+			expect( nodeList.get( 3 ).attrs.size ).to.equal( 0 );
+			expect( nodeList.get( 4 ).attrs.size ).to.equal( 0 );
+			expect( nodeList.get( 5 ).attrs.size ).to.equal( 0 );
 		} );
 
 		it( 'should change string into a set of nodes', () => {
@@ -55,6 +78,26 @@ describe( 'NodeList', () => {
 			expect( nodeList.get( 2 ).text ).to.equal( 'o' );
 			expect( nodeList.get( 2 ).attrs.getValue( attr.key ) ).to.equal( attr.value );
 		} );
+
+		it( 'should merge strings and text objects if possible', () => {
+			let attr = new Attribute( 'foo', 'bar' );
+			let nodeList = new NodeList( [ 'fo', new Text( 'o' ), new Text( 'x', [ attr ] ), new Text( 'y', [ attr ] ), 'bar' ] );
+
+			expect( nodeList.length ).to.equal( 8 );
+			expect( nodeList.get( 0 ).text ).to.equal( 'f' );
+			expect( nodeList.get( 1 ).text ).to.equal( 'o' );
+			expect( nodeList.get( 2 ).text ).to.equal( 'o' );
+			expect( nodeList.get( 3 ).text ).to.equal( 'x' );
+			expect( nodeList.get( 4 ).text ).to.equal( 'y' );
+			expect( nodeList.get( 5 ).text ).to.equal( 'b' );
+			expect( nodeList.get( 6 ).text ).to.equal( 'a' );
+			expect( nodeList.get( 7 ).text ).to.equal( 'r' );
+
+			expect( nodeList._nodes.length ).to.equal( 3 );
+			expect( nodeList._nodes[ 0 ].text ).to.equal( 'foo' );
+			expect( nodeList._nodes[ 1 ].text ).to.equal( 'xy' );
+			expect( nodeList._nodes[ 2 ].text ).to.equal( 'bar' );
+		} );
 	} );
 
 	describe( 'insert', () => {
@@ -72,6 +115,18 @@ describe( 'NodeList', () => {
 			expect( outerList.get( 4 ).text ).to.equal( 'x' );
 			expect( outerList.get( 5 ).text ).to.equal( 'o' );
 		} );
+
+		it( 'should merge inserted text objects if possible', () => {
+			let attr = new Attribute( 'foo', 'bar' );
+			let outerList = new NodeList( [ 'foo', new Text( 'bar', [ attr ] ) ] );
+			let innerList = new NodeList( [ 'x' , new Text( 'y', [ attr ] ) ] );
+
+			outerList.insert( 3, innerList );
+
+			expect( outerList._nodes.length ).to.equal( 2 );
+			expect( outerList._nodes[ 0 ].text ).to.equal( 'foox' );
+			expect( outerList._nodes[ 1 ].text ).to.equal( 'ybar' );
+		} );
 	} );
 
 	describe( 'remove', () => {
@@ -91,6 +146,56 @@ describe( 'NodeList', () => {
 			expect( removed.get( 1 ).text ).to.equal( 'b' );
 			expect( removed.get( 2 ).text ).to.equal( 'a' );
 		} );
+
+		it( 'should merge text objects left in node list possible', () => {
+			let attr = new Attribute( 'foo', 'bar' );
+			let nodeList = new NodeList( [ 'foo', new Text( 'xxx', [ attr ] ), 'bar' ] );
+
+			nodeList.remove( 2, 5 );
+
+			expect( nodeList._nodes.length ).to.equal( 1 );
+			expect( nodeList._nodes[ 0 ].text ).to.equal( 'foar' );
+		} );
+
+		it( 'should return empty node list and do nothing if node list removed from is also empty', () => {
+			let nodeList = new NodeList();
+			let result = nodeList.remove( 2, 3 );
+
+			expect( result.length ).to.equal( 0 );
+		} );
+	} );
+
+	describe( 'indexOf', () => {
+		let nodeList, p;
+
+		beforeEach( () => {
+			p = new Element( 'p' );
+			nodeList = new NodeList( [ 'abc', p, 'def' ] );
+		} );
+
+		it( 'should return index of specified element', () => {
+			let index = nodeList.indexOf( p );
+
+			expect( index ).to.equal( 3 );
+		} );
+
+		it( 'should return index of specified text node', () => {
+			let textNode = nodeList.get( 5 );
+			let index = nodeList.indexOf( textNode );
+
+			expect( index ).to.equal( 5 );
+		} );
+
+		it( 'should return -1 if specified element is not a part of a node list', () => {
+			expect( nodeList.indexOf( new Element( 'p' ) ) ).to.equal( -1 );
+		} );
+
+		it( 'should return -1 if specified text node is not a part of a node list', () => {
+			let text = new Text( 'foobar' );
+			let textNode = text.getTextNode( 2, 2 );
+
+			expect( nodeList.indexOf( textNode ) ).to.equal( -1 );
+		} );
 	} );
 
 	describe( 'iterator', () => {
@@ -107,4 +212,59 @@ describe( 'NodeList', () => {
 			expect( i ).to.equal( 3 );
 		} );
 	} );
+
+	describe( '_splitNodeAt', () => {
+		it( 'should split text object into two text objects', () => {
+			let nodeList = new NodeList( 'abcd' );
+			nodeList._splitNodeAt( 2 );
+
+			expect( nodeList._nodes.length ).to.equal( 2 );
+			expect( nodeList._nodes[ 0 ].text ).to.equal( 'ab' );
+			expect( nodeList._nodes[ 1 ].text ).to.equal( 'cd' );
+		} );
+
+		it( 'should do nothing if node before and after index are different', () => {
+			let nodeList = new NodeList( [ new Text( 'ab', [ new Attribute( 'foo', true ) ] ), 'cd' ] );
+			nodeList._splitNodeAt( 2 );
+
+			expect( nodeList._nodes.length ).to.equal( 2 );
+			expect( nodeList._nodes[ 0 ].text ).to.equal( 'ab' );
+			expect( nodeList._nodes[ 1 ].text ).to.equal( 'cd' );
+		} );
+	} );
+
+	describe( '_mergeNodeAt', () => {
+		it( 'should merge two text object if they have same attributes', () => {
+			let attr = new Attribute( 'foo', true );
+			let nodeList = new NodeList( [ 'ab', new Text( 'cd', [ attr ] ) ] );
+			nodeList._nodes[ 1 ].attrs.delete( attr.key );
+
+			expect( nodeList._nodes.length ).to.equal( 2 );
+
+			nodeList._mergeNodeAt( 2 );
+
+			expect( nodeList._nodes.length ).to.equal( 1 );
+			expect( nodeList._nodes[ 0 ].text ).to.equal( 'abcd' );
+		} );
+
+		it( 'should do nothing if text objects has different attributes', () => {
+			let nodeList = new NodeList( [ new Text( 'ab', [ new Attribute( 'foo', true ) ] ), 'cd' ] );
+			nodeList._mergeNodeAt( 2 );
+
+			expect( nodeList._nodes.length ).to.equal( 2 );
+			expect( nodeList._nodes[ 0 ].text ).to.equal( 'ab' );
+			expect( nodeList._nodes[ 1 ].text ).to.equal( 'cd' );
+		} );
+	} );
+
+	describe( '_getCharIndex', () => {
+		it( 'should return offset of given index from the beginning of the text node', () => {
+			let nodeList = new NodeList( [ new Text( 'ab', [ new Attribute( 'foo', true ) ] ), 'cd' ] );
+			let charIndexC = nodeList._getCharIndex( 2 );
+			let charIndexD = nodeList._getCharIndex( 3 );
+
+			expect( charIndexC ).to.equal( 0 );
+			expect( charIndexD ).to.equal( 1 );
+		} );
+	} );
 } );