瀏覽代碼

Added support for selection.

Piotrek Koszuliński 9 年之前
父節點
當前提交
1dfba39ed5
共有 2 個文件被更改,包括 213 次插入17 次删除
  1. 102 4
      packages/ckeditor5-engine/tests/_utils-tests/model.js
  2. 111 13
      packages/ckeditor5-engine/tests/_utils/model.js

+ 102 - 4
packages/ckeditor5-engine/tests/_utils-tests/model.js

@@ -210,16 +210,15 @@ describe( 'model test utils', () => {
 		test( 'sets complex attributes', {
 			data: '<a foo={"a":1,"b":"c"}></a>',
 			check() {
-				expect( root.getChild( 0 ).getAttribute( 'a' ) ).to.equal( 1 );
-				expect( root.getChild( 0 ).getAttribute( 'b' ) ).to.equal( 'c' );
+				expect( root.getChild( 0 ).getAttribute( 'foo' ) ).to.have.property( 'a', 1 );
 			}
 		} );
 
 		test( 'sets text attributes', {
 			data: '<$text bold=true italic=true>foo</$text><$text bold=true>bar</$text>bom',
 			check() {
-				expect( root.getChildCount() ).to.equal( 3 );
-				expect( root.getChild( 0 ) ).to.have.property( 'text', 'foo' );
+				expect( root.getChildCount() ).to.equal( 9 );
+				expect( root.getChild( 0 ) ).to.have.property( 'character', 'f' );
 				expect( root.getChild( 0 ).getAttribute( 'italic' ) ).to.equal( true );
 			}
 		} );
@@ -248,6 +247,101 @@ describe( 'model test utils', () => {
 			} ).to.throw();
 		} );
 
+		it( 'throws when missing closing tag for text', () => {
+			expect( () => {
+				setData( document, 'main', '</$text>' );
+			} ).to.throw();
+		} );
+
+		describe( 'selection', () => {
+			const getDataOptions = { selection: true };
+
+			test( 'sets collapsed selection in an element', {
+				data: '<a><selection /></a>',
+				getDataOptions,
+				check() {
+					expect( document.selection.getFirstPosition().parent ).to.have.property( 'name', 'a' );
+				}
+			} );
+
+			test( 'sets collapsed selection between elements', {
+				data: '<a></a><selection /><b></b>',
+				getDataOptions
+			} );
+
+			test( 'sets collapsed selection before a text', {
+				data: '<a></a><selection />foo',
+				getDataOptions
+			} );
+
+			test( 'sets collapsed selection after a text', {
+				data: 'foo<selection />',
+				getDataOptions
+			} );
+
+			test( 'sets collapsed selection within a text', {
+				data: 'foo<selection />bar',
+				getDataOptions,
+				check() {
+					expect( root.getChildCount() ).to.equal( 6 );
+				}
+			} );
+
+			test( 'sets selection attributes', {
+				data: 'foo<selection bold=true italic=true />bar',
+				getDataOptions,
+				check() {
+					expect( root.getChildCount() ).to.equal( 6 );
+				}
+			} );
+
+			test( 'sets collapsed selection between text and text with attributes', {
+				data: 'foo<selection /><$text bold=true>bar</$text>',
+				getDataOptions,
+				check() {
+					expect( root.getChildCount() ).to.equal( 6 );
+					expect( document.selection.getAttribute( 'bold' ) ).to.be.undefined;
+				}
+			} );
+
+			test( 'sets selection containing an element', {
+				data: 'x<selection><a></a></selection>',
+				getDataOptions
+			} );
+
+			test( 'sets selection with attribute containing an element', {
+				data: 'x<selection bold=true><a></a></selection>',
+				getDataOptions
+			} );
+
+			test( 'sets a backward selection containing an element', {
+				data: 'x<selection backward bold=true><a></a></selection>',
+				getDataOptions
+			} );
+
+			test( 'sets selection within a text', {
+				data: 'x<selection bold=true>y</selection>z',
+				getDataOptions
+			} );
+
+			test( 'sets selection within a text with different attributes', {
+				data: '<$text bold=true>fo<selection bold=true>o</$text>ba</selection>r',
+				getDataOptions
+			} );
+
+			it( 'throws when missing selection start', () => {
+				expect( () => {
+					setData( document, 'main', 'foo</selection>' );
+				} ).to.throw();
+			} );
+
+			it( 'throws when missing selection end', () => {
+				expect( () => {
+					setData( document, 'main', '<selection>foo' );
+				} ).to.throw();
+			} );
+		} );
+
 		function test( title, options ) {
 			it( title, () => {
 				let output = options.output || options.data;
@@ -255,6 +349,10 @@ describe( 'model test utils', () => {
 				setData( document, 'main', options.data, options.setDataOptions );
 
 				expect( getData( document, 'main', options.getDataOptions ) ).to.equal( output );
+
+				if ( options.check ) {
+					options.check();
+				}
 			} );
 		}
 	} );

+ 111 - 13
packages/ckeditor5-engine/tests/_utils/model.js

@@ -18,6 +18,7 @@ import Element from '/ckeditor5/engine/treemodel/element.js';
  * @param {String} rootName
  * @param {Object} [options]
  * @param {Boolean} [options.selection] Whether to write the selection.
+ * @returns {String} The stringified data.
  */
 export function getData( document, rootName, options ) {
 	const walker = new TreeWalker( {
@@ -46,31 +47,92 @@ export function getData( document, rootName, options ) {
 	return ret;
 }
 
+/**
+ * Sets the contents of the model and the selection in it.
+ *
+ * @param {engine.treeModel.Document} document
+ * @param {String} rootName
+ * @param {String} data
+ */
 export function setData( document, rootName, data ) {
 	let appendTo = document.getRoot( rootName );
 	const path = [];
+	let selectionStart, selectionEnd, selectionAttributes, textAttributes;
 
-	for ( let token of tokenize( data ) ) {
-		if ( token.type == 'text' ) {
-			appendTo.appendChildren( new Text( token.text, token.attributes ) );
-		} else if ( token.type == 'openingTag' ) {
+	const handlers = {
+		text( token ) {
+			appendTo.appendChildren( new Text( token.text, textAttributes ) );
+		},
+
+		textStart( token ) {
+			textAttributes = token.attributes;
+			path.push( '$text' );
+		},
+
+		textEnd() {
+			if ( path.pop() != '$text' ) {
+				throw new Error( 'Parse error - unexpected closing tag.' );
+			}
+
+			textAttributes = null;
+		},
+
+		openingTag( token ) {
 			let el = new Element( token.name, token.attributes );
 			appendTo.appendChildren( el );
 
 			appendTo = el;
+
 			path.push( token.name );
-		} else {
+		},
+
+		closingTag( token ) {
 			if ( path.pop() != token.name ) {
 				throw new Error( 'Parse error - unexpected closing tag.' );
 			}
 
 			appendTo = appendTo.parent;
+		},
+
+		collapsedSelection( token ) {
+			document.selection.collapse( appendTo, 'END' );
+			document.selection.setAttributesTo( token.attributes );
+		},
+
+		selectionStart( token ) {
+			selectionStart = Position.createFromParentAndOffset( appendTo, appendTo.getChildCount() );
+			selectionAttributes = token.attributes;
+		},
+
+		selectionEnd() {
+			if ( !selectionStart ) {
+				throw new Error( 'Parse error - missing selection start' );
+			}
+
+			selectionEnd = Position.createFromParentAndOffset( appendTo, appendTo.getChildCount() );
+
+			document.selection.setRanges(
+				[ new Range( selectionStart, selectionEnd ) ],
+				selectionAttributes.backward
+			);
+
+			delete selectionAttributes.backward;
+
+			document.selection.setAttributesTo( selectionAttributes );
 		}
+	};
+
+	for ( let token of tokenize( data ) ) {
+		handlers[ token.type ]( token );
 	}
 
 	if ( path.length ) {
 		throw new Error( 'Parse error - missing closing tags: ' + path.join( ', ' ) + '.' );
 	}
+
+	if ( selectionStart && !selectionEnd ) {
+		throw new Error( 'Parse error - missing selection end.' );
+	}
 }
 
 // -- getData helpers ---------------------------------------------------------
@@ -167,16 +229,32 @@ function writeSelection( currentPosition, selection ) {
 // -- setData helpers ---------------------------------------------------------
 
 const patterns = {
-	textTag: /^<\$text ([^>]+)>([\s\S]+?)<\/\$text>/,
+	selection: /^<(\/?selection)( [^>]*)?>/,
 	tag: /^<([^>]+)>/,
 	text: /^[^<]+/
 };
+
 const handlers = {
-	textTag( match ) {
+	selection( match ) {
+		const tagName = match[ 1 ];
+		const tagExtension = match[ 2 ] || '';
+
+		if ( tagName[ 0 ] == '/' ) {
+			return {
+				type: 'selectionEnd'
+			};
+		}
+
+		if ( tagExtension.endsWith( ' /' ) ) {
+			return {
+				type: 'collapsedSelection',
+				attributes: parseAttributes( tagExtension.slice( 1, -2 ) )
+			};
+		}
+
 		return {
-			type: 'text',
-			attributes: parseAttributes( match[ 1 ] ),
-			text: match[ 2 ]
+			type: 'selectionStart',
+			attributes: parseAttributes( tagExtension.slice( 1 ) )
 		};
 	},
 
@@ -185,6 +263,19 @@ const handlers = {
 		const tagName = tagContents.shift();
 		const attrs = tagContents.join( ' ' );
 
+		if ( tagName == '/$text' ) {
+			return {
+				type: 'textEnd'
+			};
+		}
+
+		if ( tagName == '$text' ) {
+			return {
+				type: 'textStart',
+				attributes: parseAttributes( attrs )
+			};
+		}
+
 		if ( tagName[ 0 ] == '/' ) {
 			return {
 				type: 'closingTag',
@@ -235,12 +326,14 @@ function consumeNextToken( data ) {
 	throw new Error( 'Parse error - unpexpected token: ' + data + '.' );
 }
 
-function parseAttributes( attrsString  ) {
+function parseAttributes( attrsString ) {
+	attrsString = attrsString.trim();
+
 	if ( !attrsString  ) {
 		return {};
 	}
 
-	const pattern = /(\w+)=("[^"]+"|[^\s]+)\s*/;
+	const pattern = /(?:backward|(\w+)=("[^"]+"|[^\s]+))\s*/;
 	const attrs = {};
 
 	while ( attrsString ) {
@@ -250,7 +343,12 @@ function parseAttributes( attrsString  ) {
 			throw new Error( 'Parse error - unexpected token: ' + attrsString + '.' );
 		}
 
-		attrs[ match[ 1 ] ] = JSON.parse( match[ 2 ] );
+		if ( match[ 0 ].trim() == 'backward' ) {
+			attrs.backward = true;
+		} else {
+			attrs[ match[ 1 ] ] = JSON.parse( match[ 2 ] );
+		}
+
 		attrsString = attrsString.slice( match[ 0 ].length );
 	}