8
0
فهرست منبع

Merge pull request #346 from ckeditor/t/312

wrapPosition in treeView.Writer
Piotr Jasiun 9 سال پیش
والد
کامیت
670500c8f4

+ 32 - 1
packages/ckeditor5-engine/src/treeview/position.js

@@ -6,6 +6,7 @@
 'use strict';
 
 import utils from '../../utils/utils.js';
+import Text from './text.js';
 
 /**
  * Position in the tree. Position is always located before or after a node.
@@ -16,7 +17,7 @@ export default class Position {
 	/**
 	 * Creates a position.
 	 *
-	 * @param {engine.treeView.Element} parent Position parent element.
+	 * @param {engine.treeView.Node} parent Position parent node.
 	 * @param {Number} offset Position offset.
 	 */
 	constructor( parent, offset ) {
@@ -35,6 +36,36 @@ export default class Position {
 		this.offset = offset;
 	}
 
+	/**
+	 * Node directly after the position. Equals `null` when there is no node after position or position is located
+	 * inside text node.
+	 *
+	 * @readonly
+	 * @type {engine.treeView.Node}
+	 */
+	get nodeAfter() {
+		if ( this.parent instanceof Text ) {
+			return null;
+		}
+
+		return this.parent.getChild( this.offset ) || null;
+	}
+
+	/**
+	 * Node directly before the position. Equals `null` when there is no node before position or position is located
+	 * inside text node.
+	 *
+	 * @readonly
+	 * @type {engine.treeView.Node}
+	 */
+	get nodeBefore() {
+		if ( this.parent instanceof Text ) {
+			return null;
+		}
+
+		return this.parent.getChild( this.offset - 1 ) || null;
+	}
+
 	/**
 	 * Returns a new instance of Position with offset incremented by `shift` value.
 	 *

+ 154 - 38
packages/ckeditor5-engine/src/treeview/writer.js

@@ -64,8 +64,12 @@ import DocumentFragment from './documentfragment.js';
 			return Position.createFromPosition( position );
 		}
 
-		const parentIsText = positionParent instanceof Text;
-		const length = parentIsText ? positionParent.data.length : positionParent.getChildCount();
+		// Break text and start again in new position.
+		if ( positionParent instanceof Text ) {
+			return this.breakAttributes( breakTextNode( position ) );
+		}
+
+		const length = positionParent.getChildCount();
 
 		// <p>foo<b><u>bar|</u></b></p>
 		// <p>foo<b><u>bar</u>|</b></p>
@@ -90,40 +94,23 @@ import DocumentFragment from './documentfragment.js';
 		else {
 			const offsetAfter = positionParent.getIndex() + 1;
 
-			if ( parentIsText ) {
-				// Break text.
-				// Get part of the text that need to be moved.
-				const textToMove = positionParent.data.slice( positionOffset );
-
-				// Leave rest of the text in position's parent.
-				positionParent.data = positionParent.data.slice( 0, positionOffset );
+			// Break element.
+			const clonedNode = positionParent.clone();
 
-				// Insert new text node after position's parent text node.
-				positionParent.parent.insertChildren( offsetAfter, new Text( textToMove ) );
+			// Insert cloned node to position's parent node.
+			positionParent.parent.insertChildren( offsetAfter, clonedNode );
 
-				// Create new position to work on.
-				const newPosition = new Position( positionParent.parent, offsetAfter );
+			// Get nodes to move.
+			const count = positionParent.getChildCount() - positionOffset;
+			const nodesToMove = positionParent.removeChildren( positionOffset, count );
 
-				return this.breakAttributes( newPosition );
-			} else {
-				// Break element.
-				const clonedNode = positionParent.clone();
+			// Move nodes to cloned node.
+			clonedNode.appendChildren( nodesToMove );
 
-				// Insert cloned node to position's parent node.
-				positionParent.parent.insertChildren( offsetAfter, clonedNode );
+			// Create new position to work on.
+			const newPosition = new Position( positionParent.parent, offsetAfter );
 
-				// Get nodes to move.
-				const count = positionParent.getChildCount() - positionOffset;
-				const nodesToMove = positionParent.removeChildren( positionOffset, count );
-
-				// Move nodes to cloned node.
-				clonedNode.appendChildren( nodesToMove );
-
-				// Create new position to work on.
-				const newPosition = new Position( positionParent.parent, offsetAfter );
-
-				return this.breakAttributes( newPosition );
-			}
+			return this.breakAttributes( newPosition );
 		}
 	}
 
@@ -208,15 +195,11 @@ import DocumentFragment from './documentfragment.js';
 			return position;
 		}
 
-		// When selection is between two text nodes.
+		// When position is between two text nodes.
 		if ( nodeBefore instanceof Text && nodeAfter instanceof Text ) {
-			// Merge text data into first text node and remove second one.
-			const nodeBeforeLength = nodeBefore.data.length;
-			nodeBefore.data += nodeAfter.data;
-			positionParent.removeChildren( positionOffset );
-
-			return new Position( nodeBefore, nodeBeforeLength );
+			return mergeTextNodes( nodeBefore, nodeAfter );
 		}
+
 		// When selection is between same nodes.
 		else if ( nodeBefore.isSimilar( nodeAfter ) ) {
 			// Move all children nodes from node placed after selection and remove that node.
@@ -330,6 +313,8 @@ import DocumentFragment from './documentfragment.js';
 	 *
 	 * Throws {@link utils.CKEditorError} `treeview-writer-invalid-range-container` when {@link engine.treeView.Range#start}
 	 * and {@link engine.treeView.Range#end} positions are not placed inside same parent container.
+	 * Throws {@link utils.CKEditorError} `treeview-writer-wrap-invalid-attribute` when passed attribute element is not
+	 * an instance of {engine.treeView.AttributeElement AttributeElement}.
 	 *
 	 * @param {engine.treeView.Range} range Range to wrap.
 	 * @param {engine.treeView.AttributeElement} attribute Attribute element to use as wrapper.
@@ -381,6 +366,67 @@ import DocumentFragment from './documentfragment.js';
 		return new Range( start, end );
 	}
 
+	/**
+	 * Wraps position with provided attribute. Returns new position after wrapping. This method will also merge newly
+	 * added attribute with its siblings whenever possible.
+	 *
+	 * Throws {@link utils.CKEditorError} `treeview-writer-wrap-invalid-attribute` when passed attribute element is not
+	 * an instance of {engine.treeView.AttributeElement AttributeElement}.
+	 *
+	 * @param {engine.treeView.Position} position
+	 * @param {engine.treeView.AttributeElement} attribute
+	 * @returns {Position} New position after wrapping.
+	 */
+	wrapPosition( position, attribute ) {
+		if ( !( attribute instanceof AttributeElement ) ) {
+			/**
+			 * Attribute element need to be instance of attribute element.
+			 *
+			 * @error treeview-writer-wrap-invalid-attribute
+			 */
+			throw new CKEditorError( 'treeview-writer-wrap-invalid-attribute' );
+		}
+
+		// Return same position when trying to wrap with attribute similar to position parent.
+		if ( attribute.isSimilar( position.parent ) ) {
+			return movePositionToTextNode( Position.createFromPosition( position ) );
+		}
+
+		// When position is inside text node - break it and place new position between two text nodes.
+		if ( position.parent instanceof Text ) {
+			position = breakTextNode( position );
+		}
+
+		// Create fake element that will represent position, and will not be merged with other attributes.
+		const fakePosition = new AttributeElement();
+		fakePosition.priority = Number.POSITIVE_INFINITY;
+		fakePosition.isSimilar = () => false;
+
+		// Insert fake element in position location.
+		position.parent.insertChildren( position.offset, fakePosition );
+
+		// Range around inserted fake attribute element.
+		const wrapRange = new Range( position, position.getShiftedBy( 1 ) );
+
+		// Wrap fake element with attribute (it will also merge if possible).
+		this.wrap( wrapRange, attribute );
+
+		// Remove fake element and place new position there.
+		const newPosition = new Position( fakePosition.parent, fakePosition.getIndex() );
+		fakePosition.remove();
+
+		// If position is placed between text nodes - merge them and return position inside.
+		const nodeBefore = newPosition.nodeBefore;
+		const nodeAfter = newPosition.nodeAfter;
+
+		if ( nodeBefore instanceof Text && nodeAfter instanceof Text ) {
+			return mergeTextNodes( nodeBefore, nodeAfter );
+		}
+
+		// If position is next to text node - move position inside.
+		return movePositionToTextNode( newPosition );
+	}
+
 	/**
 	 * Unwraps nodes within provided range from attribute element.
 	 *
@@ -564,3 +610,73 @@ function wrapChildren( writer, parent, startOffset, endOffset, attribute ) {
 
 	return Range.createFromParentsAndOffsets( parent, startOffset, parent, endOffset );
 }
+
+// Returns new position that is moved to near text node. Returns same position if there is no text node before of after
+// specified position.
+//
+//		<p>{foo}|</p>  ->  <p>{foo|}</p>
+//		<p>|{foo}</p>  ->  <p>{|foo}</p>
+//
+// @param {engine.treeView.Position} position
+// @returns {engine.treeView.Position} Position located inside text node or same position if there is no text nodes
+// before or after position location.
+function movePositionToTextNode( position ) {
+	const nodeBefore = position.nodeBefore;
+
+	if ( nodeBefore && nodeBefore instanceof Text ) {
+		return new Position( nodeBefore, nodeBefore.data.length );
+	}
+
+	const nodeAfter = position.nodeAfter;
+
+	if ( nodeAfter && nodeAfter instanceof Text ) {
+		return new Position( nodeAfter, 0 );
+	}
+
+	return position;
+}
+
+// Breaks text node into two text nodes when possible.
+//
+//		<p>{foo|bar}</p> -> <p>{foo}|{bar}</p>
+//		<p>{|foobar}</p> -> <p>|{foobar}</p>
+//		<p>{foobar|}</p> -> <p>{foobar}|</p>
+//
+// @param {engine.treeView.Position} position Position that need to be placed inside text node.
+// @returns {engine.treeView.Position} New position after breaking text node.
+function breakTextNode( position ) {
+	if ( position.offset == position.parent.data.length ) {
+		return new Position( position.parent.parent, position.parent.getIndex() + 1 );
+	}
+
+	if ( position.offset === 0 ) {
+		return new Position( position.parent.parent, position.parent.getIndex() );
+	}
+
+	// Get part of the text that need to be moved.
+	const textToMove = position.parent.data.slice( position.offset );
+
+	// Leave rest of the text in position's parent.
+	position.parent.data = position.parent.data.slice( 0, position.offset );
+
+	// Insert new text node after position's parent text node.
+	position.parent.parent.insertChildren( position.parent.getIndex() + 1, new Text( textToMove ) );
+
+	// Return new position between two newly created text nodes.
+	return new Position( position.parent.parent, position.parent.getIndex() + 1 );
+}
+
+// Merges two text nodes into first node. Removes second node and returns merge position.
+//
+// @param {engine.treeView.Text} t1 First text node to merge. Data from second text node will be moved at the end of
+// this text node.
+// @param {engine.treeView.Text} t2 Second text node to merge. This node will be removed after merging.
+// @returns {engine.treeView.Position} Position after merging text nodes.
+function mergeTextNodes( t1, t2 ) {
+	// Merge text data into first text node and remove second one.
+	const nodeBeforeLength = t1.data.length;
+	t1.data += t2.data;
+	t2.remove();
+
+	return new Position( t1, nodeBeforeLength );
+}

+ 50 - 0
packages/ckeditor5-engine/tests/treeview/position.js

@@ -24,6 +24,56 @@ describe( 'Position', () => {
 		} );
 	} );
 
+	describe( 'nodeBefore', () => {
+		it( 'should equal to node that is before position', () => {
+			const b1 = new Element( 'b' );
+			const el = new Element( 'p', null, [ b1 ] );
+			const position = new Position( el, 1 );
+
+			expect( position.nodeBefore ).to.equal( b1 );
+		} );
+
+		it( 'should equal null if there is no node before', () => {
+			const b1 = new Element( 'b' );
+			const el = new Element( 'p', null, [ b1 ] );
+			const position = new Position( el, 0 );
+
+			expect( position.nodeBefore ).to.be.null;
+		} );
+
+		it( 'should equal null if position is located inside text node', () => {
+			const text = new Text( 'foobar' );
+			const position = new Position( text, 3 );
+
+			expect( position.nodeBefore ).to.be.null;
+		} );
+	} );
+
+	describe( 'nodeAfter', () => {
+		it( 'should equal to node that is after position', () => {
+			const b1 = new Element( 'b' );
+			const el = new Element( 'p', null, [ b1 ] );
+			const position = new Position( el, 0 );
+
+			expect( position.nodeAfter ).to.equal( b1 );
+		} );
+
+		it( 'should equal null if there is no node before', () => {
+			const b1 = new Element( 'b' );
+			const el = new Element( 'p', null, [ b1 ] );
+			const position = new Position( el, 1 );
+
+			expect( position.nodeAfter ).to.be.null;
+		} );
+
+		it( 'should equal null if position is located inside text node', () => {
+			const text = new Text( 'foobar' );
+			const position = new Position( text, 3 );
+
+			expect( position.nodeAfter ).to.be.null;
+		} );
+	} );
+
 	describe( 'getShiftedBy', () => {
 		it( 'returns new instance with shifted offset', () => {
 			const position = new Position( parentMock, 10 );

+ 314 - 0
packages/ckeditor5-engine/tests/treeview/writer/wrapposition.js

@@ -0,0 +1,314 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import Writer from '/ckeditor5/engine/treeview/writer.js';
+import Text from '/ckeditor5/engine/treeview/text.js';
+import Element from '/ckeditor5/engine/treeview/element.js';
+import ContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
+import AttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
+import Position from '/ckeditor5/engine/treeview/position.js';
+import utils from '/tests/engine/treeview/writer/_utils/utils.js';
+import { DEFAULT_PRIORITY } from '/ckeditor5/engine/treeview/attributeelement.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+
+describe( 'wrapPosition', () => {
+	const create = utils.create;
+	const test = utils.test;
+	let writer;
+
+	beforeEach( () => {
+		writer = new Writer();
+	} );
+
+	it( 'should throw error when element is not instance of AttributeElement', () => {
+		const container = new ContainerElement( 'p', null, new Text( 'foo' ) );
+		const position = new Position( container, 0 );
+		const b = new Element( 'b' );
+
+		expect( () => {
+			writer.wrapPosition( position, b );
+		} ).to.throw( CKEditorError, 'treeview-writer-wrap-invalid-attribute' );
+	} );
+
+	it( 'should wrap position at the beginning of text node', () => {
+		// <p>{|foobar}</p>
+		// wrap with <b>
+		// <p><b>|<b>{foobar}</p>
+		const description = {
+			instanceOf: ContainerElement,
+			name: 'p',
+			children: [
+				{ instanceOf: Text, data: 'foobar', position: 0 }
+			]
+		};
+		const created = create( writer, description );
+		const newPosition = writer.wrapPosition( created.position, new AttributeElement( 'b' ) );
+		test( writer, newPosition, created.node, {
+			instanceOf: ContainerElement,
+			name: 'p',
+			children: [
+				{ instanceOf: AttributeElement, name: 'b', position: 0 },
+				{ instanceOf: Text, data: 'foobar' }
+			]
+		} );
+	} );
+
+	it( 'should wrap position inside text node', () => {
+		// <p>{foo|bar}</p>
+		// wrap with <b>
+		// <p>{foo}<b>|</b>{bar}</p>
+		const description = {
+			instanceOf: ContainerElement,
+			name: 'p',
+			children: [
+				{ instanceOf: Text, data: 'foobar', position: 3 }
+			]
+		};
+		const created = create( writer, description );
+		const newPosition = writer.wrapPosition( created.position, new AttributeElement( 'b' ) );
+		test( writer, newPosition, created.node, {
+			instanceOf: ContainerElement,
+			name: 'p',
+			children: [
+				{ instanceOf: Text, data: 'foo' },
+				{ instanceOf: AttributeElement, name: 'b', position: 0 },
+				{ instanceOf: Text, data: 'bar' }
+			]
+		} );
+	} );
+
+	it( 'should wrap position at the end of text node', () => {
+		// <p>{foobar|}</p>
+		// wrap with <b>
+		// <p>{foobar}<b>|</b></p>
+		const description = {
+			instanceOf: ContainerElement,
+			name: 'p',
+			children: [
+				{ instanceOf: Text, data: 'foobar', position: 6 }
+			]
+		};
+		const created = create( writer, description );
+		const newPosition = writer.wrapPosition( created.position, new AttributeElement( 'b' ) );
+		test( writer, newPosition, created.node, {
+			instanceOf: ContainerElement,
+			name: 'p',
+			children: [
+				{ instanceOf: Text, data: 'foobar' },
+				{ instanceOf: AttributeElement, name: 'b', position: 0 }
+			]
+		} );
+	} );
+
+	it( 'should merge with existing attributes #1', () => {
+		// <p><b>{foo}</b>|</p>
+		// wrap with <b>
+		// <p><b>{foo|}</b></p>
+		const description = {
+			instanceOf: ContainerElement,
+			name: 'p',
+			position: 1,
+			children: [
+				{
+					instanceOf: AttributeElement,
+					name: 'b',
+					children: [
+						{ instanceOf: Text, data: 'foobar' }
+					]
+				}
+			]
+		};
+
+		const created = create( writer, description );
+		const newPosition = writer.wrapPosition( created.position, new AttributeElement( 'b' ) );
+
+		test( writer, newPosition, created.node, {
+			instanceOf: ContainerElement,
+			name: 'p',
+			children: [
+				{
+					instanceOf: AttributeElement,
+					name: 'b',
+					priority: DEFAULT_PRIORITY,
+					children: [
+						{ instanceOf: Text, data: 'foobar', position: 6 }
+					]
+				}
+			]
+		} );
+	} );
+
+	it( 'should merge with existing attributes #2', () => {
+		// <p>|<b>{foo}</b></p>
+		// wrap with <b>
+		// <p><b>{|foo}</b></p>
+		const description = {
+			instanceOf: ContainerElement,
+			name: 'p',
+			position: 0,
+			children: [
+				{
+					instanceOf: AttributeElement,
+					name: 'b',
+					children: [
+						{ instanceOf: Text, data: 'foobar' }
+					]
+				}
+			]
+		};
+
+		const created = create( writer, description );
+		const newPosition = writer.wrapPosition( created.position, new AttributeElement( 'b' ) );
+
+		test( writer, newPosition, created.node, {
+			instanceOf: ContainerElement,
+			name: 'p',
+			children: [
+				{
+					instanceOf: AttributeElement,
+					name: 'b',
+					priority: DEFAULT_PRIORITY,
+					children: [
+						{ instanceOf: Text, data: 'foobar', position: 0 }
+					]
+				}
+			]
+		} );
+	} );
+
+	it( 'should wrap when inside nested attributes', () => {
+		// <p><b>{foo|bar}</b></p>
+		// wrap with <u>
+		// <p><b>{foo}</b><u><b>|</b></u><b>{bar}</b></p>
+		const description = {
+			instanceOf: ContainerElement,
+			name: 'p',
+			children: [
+				{
+					instanceOf: AttributeElement,
+					name: 'b',
+					children: [
+						{ instanceOf: Text, data: 'foobar', position: 3 }
+					]
+				}
+			]
+		};
+
+		const created = create( writer, description );
+		const newPosition = writer.wrapPosition( created.position, new AttributeElement( 'u' ) );
+		test( writer, newPosition, created.node, {
+			instanceOf: ContainerElement,
+			name: 'p',
+			children: [
+				{
+					instanceOf: AttributeElement,
+					name: 'b',
+					children: [
+						{ instanceOf: Text, data: 'foo' }
+					]
+				},
+				{
+					instanceOf: AttributeElement,
+					name: 'u',
+					children: [
+						{ instanceOf: AttributeElement, name: 'b', children: [] }
+					]
+				},
+				{
+					instanceOf: AttributeElement,
+					name: 'b',
+					children: [
+						{ instanceOf: Text, data: 'bar' }
+					]
+				}
+			]
+		}  );
+	} );
+
+	it( 'should merge when wrapping between same attribute', () => {
+		// <p><b>{foo}</b>|<b>{bar}</b></p>
+		// wrap with <b>
+		// <p><b>{foo|bar}</b></p>
+		const description = {
+			instanceOf: ContainerElement,
+			name: 'p',
+			position: 1,
+			children: [
+				{
+					instanceOf: AttributeElement,
+					name: 'b',
+					children: [
+						{ instanceOf: Text, data: 'foo' }
+					]
+				},
+				{
+					instanceOf: AttributeElement,
+					name: 'b',
+					children: [
+						{ instanceOf: Text, data: 'bar' }
+					]
+				}
+			]
+		};
+
+		const created = create( writer, description );
+		const newPosition = writer.wrapPosition( created.position, new AttributeElement( 'b' ) );
+
+		test( writer, newPosition, created.node, {
+			instanceOf: ContainerElement,
+			name: 'p',
+			children: [
+				{
+					instanceOf: AttributeElement,
+					name: 'b',
+					children: [
+						{ instanceOf: Text, data: 'foobar', position: 3 }
+					]
+				}
+			]
+		}  );
+	} );
+
+	it( 'should return same position when inside same attribute', () => {
+		// <p><b>{foobar}|</b></p>
+		// wrap with <b>
+		// <p><b>{foobar|}</b></p>
+		const description = {
+			instanceOf: ContainerElement,
+			name: 'p',
+			children: [
+				{
+					instanceOf: AttributeElement,
+					name: 'b',
+					position: 1,
+					children: [
+						{ instanceOf: Text, data: 'foobar' }
+					]
+				}
+			]
+		};
+
+		const created = create( writer, description );
+		const newPosition = writer.wrapPosition( created.position, new AttributeElement( 'b' ) );
+
+		test( writer, newPosition, created.node, {
+			instanceOf: ContainerElement,
+			name: 'p',
+			children: [
+				{
+					instanceOf: AttributeElement,
+					name: 'b',
+					children: [
+						{ instanceOf: Text, data: 'foobar', position: 6 }
+					]
+				}
+			]
+		} );
+	} );
+} );