Explorar el Código

Merge pull request #358 from ckeditor/t/252

Improvements to treeView.writer.wrap().
Piotr Jasiun hace 9 años
padre
commit
1ecdfe9d7c

+ 160 - 1
packages/ckeditor5-engine/src/treeview/writer.js

@@ -309,7 +309,7 @@ import DocumentFragment from './documentfragment.js';
 	}
 
 	/**
-	 * Wraps elements within range with provided attribute element.
+	 * Wraps elements within range with provided {@link engine.treeView.AttributeElement AttributeElement}.
 	 *
 	 * 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.
@@ -344,6 +344,23 @@ import DocumentFragment from './documentfragment.js';
 			return range;
 		}
 
+		// Range around one element.
+		if ( range.end.isEqual( range.start.getShiftedBy( 1 ) ) ) {
+			const node = range.start.nodeAfter;
+
+			if ( node instanceof AttributeElement && wrapAttributeElement( attribute, node ) ) {
+				return range;
+			}
+		}
+
+		// Range is inside single attribute and spans on all children.
+		if ( rangeSpansOnAllChildren( range ) && wrapAttributeElement( attribute, range.start.parent ) ) {
+			const parent = range.start.parent.parent;
+			const index = range.start.parent.getIndex();
+
+			return Range.createFromParentsAndOffsets( parent, index, parent, index + 1 ) ;
+		}
+
 		// Break attributes at range start and end.
 		const { start: breakStart, end: breakEnd } = this.breakRange( range );
 		const parentContainer = breakStart.parent;
@@ -462,6 +479,18 @@ import DocumentFragment from './documentfragment.js';
 			return range;
 		}
 
+		// Range around one element - check if AttributeElement can be unwrapped partially when it's not similar.
+		// For example:
+		// <b class="foo bar" title="baz"></b> unwrap with:	<b class="foo"></p> result: <b class"bar" title="baz"></b>
+		if ( range.end.isEqual( range.start.getShiftedBy( 1 ) ) ) {
+			const node = range.start.nodeAfter;
+
+			// Unwrap single attribute element.
+			if ( !attribute.isSimilar( node ) && node instanceof AttributeElement && unwrapAttributeElement( attribute, node ) ) {
+				return range;
+			}
+		}
+
 		// Break attributes at range start and end.
 		const { start: breakStart, end: breakEnd } = this.breakRange( range );
 		const parentContainer = breakStart.parent;
@@ -679,4 +708,134 @@ function mergeTextNodes( t1, t2 ) {
 	t2.remove();
 
 	return new Position( t1, nodeBeforeLength );
+}
+
+// Wraps one {@link engine.treeView.AttributeElement AttributeElement} into another by merging them if possible.
+// Two AttributeElements can be merged when there is no attribute or style conflicts between them.
+// When merging is possible - all attributes, styles and classes are moved from wrapper element to element being
+// wrapped.
+//
+// @param {engine.treeView.AttributeElement} wrapper Wrapper AttributeElement.
+// @param {engine.treeView.AttributeElement} toWrap AttributeElement to wrap using wrapper element.
+// @returns {Boolean} Returns `true` if elements are merged.
+function wrapAttributeElement( wrapper, toWrap ) {
+	// Can't merge if name or priority differs.
+	if ( wrapper.name !== toWrap.name || wrapper.priority !== toWrap.priority ) {
+		return false;
+	}
+
+	// Check if attributes can be merged.
+	for ( let key of wrapper.getAttributeKeys() ) {
+		// Classes and styles should be checked separately.
+		if ( key === 'class' || key === 'style' ) {
+			continue;
+		}
+
+		// If some attributes are different we cannot wrap.
+		if ( toWrap.hasAttribute( key ) && toWrap.getAttribute( key ) !== wrapper.getAttribute( key ) ) {
+			return false;
+		}
+	}
+
+	// Check if styles can be merged.
+	for ( let key of wrapper.getStyleNames() ) {
+		if ( toWrap.hasStyle( key ) && toWrap.getStyle( key ) !== wrapper.getStyle( key ) ) {
+			return false;
+		}
+	}
+
+	// Move all attributes/classes/styles from wrapper to wrapped AttributeElement.
+	for ( let key of wrapper.getAttributeKeys() ) {
+		// Classes and styles should be checked separately.
+		if ( key === 'class' || key === 'style' ) {
+			continue;
+		}
+
+		// Move only these attributes that are not present - other are similar.
+		if ( !toWrap.hasAttribute( key ) ) {
+			toWrap.setAttribute( key, wrapper.getAttribute( key ) );
+		}
+	}
+
+	for ( let key of wrapper.getStyleNames() ) {
+		if ( !toWrap.hasStyle( key ) ) {
+			toWrap.setStyle( key, wrapper.getStyle( key ) );
+		}
+	}
+
+	for ( let key of wrapper.getClassNames() ) {
+		if ( !toWrap.hasClass( key ) ) {
+			toWrap.addClass( key );
+		}
+	}
+
+	return true;
+}
+
+// Unwraps {@link engine.treeView.AttributeElement AttributeElement} from another by removing corresponding attributes,
+// classes and styles. All attributes, classes and styles from wrapper should be present inside element being unwrapped.
+//
+// @param {engine.treeView.AttributeElement} wrapper Wrapper AttributeElement.
+// @param {engine.treeView.AttributeElement} toUnwrap AttributeElement to unwrap using wrapper element.
+// @returns {Boolean} Returns `true` if elements are unwrapped.
+function unwrapAttributeElement( wrapper, toUnwrap ) {
+	// Can't unwrap if name or priority differs.
+	if ( wrapper.name !== toUnwrap.name || wrapper.priority !== toUnwrap.priority ) {
+		return false;
+	}
+
+	// Check if AttributeElement has all wrapper attributes.
+	for ( let key of wrapper.getAttributeKeys() ) {
+		// Classes and styles should be checked separately.
+		if ( key === 'class' || key === 'style' ) {
+			continue;
+		}
+
+		// If some attributes are missing or different we cannot unwrap.
+		if ( !toUnwrap.hasAttribute( key ) || toUnwrap.getAttribute( key ) !== wrapper.getAttribute( key ) ) {
+			return false;
+		}
+	}
+
+	// Check if AttributeElement has all wrapper classes.
+	if ( !toUnwrap.hasClass( ...wrapper.getClassNames() ) ) {
+		return false;
+	}
+
+	// Check if AttributeElement has all wrapper styles.
+	for ( let key of wrapper.getStyleNames() ) {
+		// If some styles are missing or different we cannot unwrap.
+		if ( !toUnwrap.hasStyle( key ) || toUnwrap.getStyle( key ) !== wrapper.getStyle( key ) ) {
+			return false;
+		}
+	}
+
+	// Remove all wrapper's attributes from unwrapped element.
+	for ( let key of wrapper.getAttributeKeys() ) {
+		// Classes and styles should be checked separately.
+		if ( key === 'class' || key === 'style' ) {
+			continue;
+		}
+
+		toUnwrap.removeAttribute( key );
+	}
+
+	// Remove all wrapper's classes from unwrapped element.
+	toUnwrap.removeClass( ...wrapper.getClassNames() );
+
+	// Remove all wrapper's styles from unwrapped element.
+	toUnwrap.removeStyle( ...wrapper.getStyleNames() );
+
+	return true;
+}
+
+// Returns `true` if range is located in same {@link engine.treeView.AttributeElement AttributeElement}
+// (`start` and `end` positions are located inside same {@link engine.treeView.AttributeElement AttributeElement}),
+// starts on 0 offset and ends after last child node.
+//
+// @param {engine.treeView.Range} Range
+// @returns {Boolean}
+function rangeSpansOnAllChildren( range ) {
+	return range.start.parent == range.end.parent && range.start.parent instanceof AttributeElement &&
+		range.start.offset === 0 && range.end.offset === range.start.parent.getChildCount();
 }

+ 6 - 8
packages/ckeditor5-engine/tests/treecontroller/advanced-converters.js

@@ -459,7 +459,6 @@ describe( 'custom attribute handling for given element', () => {
 		} );
 		modelDispatcher.on( 'removeAttribute:linkTitle:quote', modelChangeLinkAttrQuoteConverter );
 
-
 		// QUOTE VIEW TO MODEL CONVERTERS
 		viewDispatcher.on( 'element:blockquote', ( evt, data, consumable, conversionApi ) => {
 			if ( consumable.consume( data.input, { name: true } ) ) {
@@ -492,9 +491,7 @@ describe( 'custom attribute handling for given element', () => {
 
 		modelDispatcher.convertInsert( range );
 
-		// The expected value is different than in example or than what is really expected as it should be one link,
-		// but writer does not have merging feature yet.
-		expect( viewToString( viewRoot ) ).to.equal( '<div><a title="Foo title"><a href="foo.html">foo</a></a></div>' );
+		expect( viewToString( viewRoot ) ).to.equal( '<div><a href="foo.html" title="Foo title">foo</a></div>' );
 
 		// Let's change link's attributes.
 		for ( let value of range ) {
@@ -504,7 +501,7 @@ describe( 'custom attribute handling for given element', () => {
 		modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkHref', 'foo.html', 'bar.html' );
 		modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkTitle', 'Foo title', 'Bar title' );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><a title="Bar title"><a href="bar.html">foo</a></a></div>' );
+		expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html" title="Bar title">foo</a></div>' );
 
 		// Let's remove a letter from the link.
 		const removed = modelRoot.removeChildren( 0, 1 );
@@ -514,7 +511,7 @@ describe( 'custom attribute handling for given element', () => {
 			ModelRange.createFromElement( modelDoc.graveyard )
 		);
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><a title="Bar title"><a href="bar.html">oo</a></a></div>' );
+		expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html" title="Bar title">oo</a></div>' );
 
 		range = ModelRange.createFromElement( modelRoot );
 
@@ -590,7 +587,7 @@ describe( 'custom attribute handling for given element', () => {
 					'a',
 					{
 						href: 'foo.html',
-						title:'Foo source'
+						title: 'Foo source'
 					},
 					new ViewText( 'see source' )
 				)
@@ -660,7 +657,8 @@ it( 'default table view to model converter', () => {
 	let model = viewDispatcher.convert( viewTable );
 	let modelFragment = new ModelDocumentFragment( model );
 
-	expect( modelToString( modelFragment ) ).to.equal( '<paragraph>foo <$text linkHref="bar.html">bar</$text></paragraph><paragraph>abc</paragraph>' );
+	expect( modelToString( modelFragment ) )
+		.to.equal( '<paragraph>foo <$text linkHref="bar.html">bar</$text></paragraph><paragraph>abc</paragraph>' );
 } );
 
 // Model converter that converts any non-converted elements and attributes into view elements and attributes.

+ 217 - 0
packages/ckeditor5-engine/tests/treeview/writer/unwrap.js

@@ -707,5 +707,222 @@ describe( 'Writer', () => {
 				]
 			} );
 		} );
+
+		it( 'should unwrap single element by removing matching attributes', () => {
+			// <p>[<b foo="bar" baz="qux" >test</b>]</p>
+			// unwrap using <b baz="qux"></b>
+			// <p>[<b foo="bar">test</b>]</p>
+			const text = new Text( 'test' );
+			const b = new AttributeElement( 'b', {
+				foo: 'bar',
+				baz: 'qux'
+			}, text );
+			const p = new ContainerElement( 'p', null, b );
+			const wrapper = new AttributeElement( 'b', {
+				baz: 'qux'
+			} );
+			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
+
+			const newRange = writer.unwrap( range, wrapper );
+			expect( b.getAttribute( 'foo' ) ).to.equal( 'bar' );
+			expect( b.hasAttribute( 'baz' ) ).to.be.false;
+			expect( b.parent ).to.equal( p );
+			test( writer, newRange, p, {
+				instanceof: ContainerElement,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceof: AttributeElement,
+						name: 'b',
+						children: [
+							{ instanceof: Text, data: 'test' }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'should not unwrap single element when attributes are different', () => {
+			// <p>[<b foo="bar" baz="qux">test</b>]</p>
+			// unwrap using <b baz="qux" test="true"></b>
+			// <p>[<b foo="bar" baz="qux">test</b>]</p>
+			const text = new Text( 'test' );
+			const b = new AttributeElement( 'b', {
+				foo: 'bar',
+				baz: 'qux'
+			}, text );
+			const p = new ContainerElement( 'p', null, b );
+			const wrapper = new AttributeElement( 'b', {
+				baz: 'qux',
+				test: 'true'
+			} );
+			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
+
+			const newRange = writer.unwrap( range, wrapper );
+			expect( b.getAttribute( 'foo' ) ).to.equal( 'bar' );
+			expect( b.getAttribute( 'baz' ) ).to.equal( 'qux' );
+			expect( b.parent ).to.equal( p );
+			test( writer, newRange, p, {
+				instanceof: ContainerElement,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceof: AttributeElement,
+						name: 'b',
+						children: [
+							{ instanceof: Text, data: 'test' }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'should unwrap single element by removing matching classes', () => {
+			// <p>[<b class="foo bar baz">test</b>]</p>
+			// unwrap using <b class="baz foo"></b>
+			// <p>[<b class="bar">test</b>]</p>
+			const text = new Text( 'test' );
+			const b = new AttributeElement( 'b', {
+				class: 'foo bar baz'
+			}, text );
+			const p = new ContainerElement( 'p', null, b );
+			const wrapper = new AttributeElement( 'b', {
+				class: 'baz foo'
+			} );
+			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
+
+			const newRange = writer.unwrap( range, wrapper );
+			expect( b.hasClass( 'bar' ) ).to.be.true;
+			expect( b.hasClass( 'foo' ) ).to.be.false;
+			expect( b.hasClass( 'baz' ) ).to.be.false;
+			expect( b.parent ).to.equal( p );
+			test( writer, newRange, p, {
+				instanceof: ContainerElement,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceof: AttributeElement,
+						name: 'b',
+						children: [
+							{ instanceof: Text, data: 'test' }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'should not unwrap single element when classes are different', () => {
+			// <p>[<b class="foo bar baz">test</b>]</p>
+			// unwrap using <b class="baz foo qux"></b>
+			// <p>[<b class="foo bar baz">test</b>]</p>
+			const text = new Text( 'test' );
+			const b = new AttributeElement( 'b', {
+				class: 'foo bar baz'
+			}, text );
+			const p = new ContainerElement( 'p', null, b );
+			const wrapper = new AttributeElement( 'b', {
+				class: 'baz foo qux'
+			} );
+			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
+
+			const newRange = writer.unwrap( range, wrapper );
+			expect( b.hasClass( 'bar' ) ).to.be.true;
+			expect( b.hasClass( 'foo' ) ).to.be.true;
+			expect( b.hasClass( 'baz' ) ).to.be.true;
+			expect( b.parent ).to.equal( p );
+			test( writer, newRange, p, {
+				instanceof: ContainerElement,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceof: AttributeElement,
+						name: 'b',
+						children: [
+							{ instanceof: Text, data: 'test' }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'should unwrap single element by removing matching styles', () => {
+			// <p>[<b style="color:red; position:absolute; top:10px;">test</b>]</p>
+			// unwrap using <b style="position: absolute;"></b>
+			// <p>[<b style="color:red; top:10px;">test</b>]</p>
+			const text = new Text( 'test' );
+			const b = new AttributeElement( 'b', {
+				style: 'color:red; position:absolute; top:10px;'
+			}, text );
+			const p = new ContainerElement( 'p', null, b );
+			const wrapper = new AttributeElement( 'b', {
+				style: 'position: absolute;'
+			} );
+			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
+
+			const newRange = writer.unwrap( range, wrapper );
+			expect( b.getStyle( 'color' ) ).to.equal( 'red' );
+			expect( b.getStyle( 'top' ) ).to.equal( '10px' );
+			expect( b.hasStyle( 'position' ) ).to.be.false;
+			expect( b.parent ).to.equal( p );
+			test( writer, newRange, p, {
+				instanceof: ContainerElement,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceof: AttributeElement,
+						name: 'b',
+						children: [
+							{ instanceof: Text, data: 'test' }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'should not unwrap single element when styles are different', () => {
+			// <p>[<b style="color:red; position:absolute; top:10px;">test</b>]</p>
+			// unwrap using <b style="position: relative;"></b>
+			// <p>[<b style="color:red; position:absolute; top:10px;">test</b>]</p>
+			const text = new Text( 'test' );
+			const b = new AttributeElement( 'b', {
+				style: 'color:red; position:absolute; top:10px;'
+			}, text );
+			const p = new ContainerElement( 'p', null, b );
+			const wrapper = new AttributeElement( 'b', {
+				style: 'position: relative;'
+			} );
+			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
+
+			const newRange = writer.unwrap( range, wrapper );
+			expect( b.getStyle( 'color' ) ).to.equal( 'red' );
+			expect( b.getStyle( 'top' ) ).to.equal( '10px' );
+			expect( b.getStyle( 'position' ) ).to.equal( 'absolute' );
+			expect( b.parent ).to.equal( p );
+			test( writer, newRange, p, {
+				instanceof: ContainerElement,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceof: AttributeElement,
+						name: 'b',
+						children: [
+							{ instanceof: Text, data: 'test' }
+						]
+					}
+				]
+			} );
+		} );
 	} );
 } );

+ 219 - 0
packages/ckeditor5-engine/tests/treeview/writer/wrap.js

@@ -551,5 +551,224 @@ describe( 'Writer', () => {
 				]
 			} );
 		} );
+
+		it( 'should wrap single element by merging attributes', () => {
+			// <p>[<b foo="bar" one="two"></b>]</p>
+			// wrap with <b baz="qux" one="two"></b>
+			// <p>[<b foo="bar" one="two" baz="qux"></b>]</p>
+			const b = new AttributeElement( 'b', {
+				foo: 'bar',
+				one: 'two'
+			} );
+			const p = new ContainerElement( 'p', null, b );
+			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
+			const wrapper = new AttributeElement( 'b', {
+				baz: 'qux',
+				one: 'two'
+			} );
+
+			const newRange = writer.wrap( range, wrapper );
+			expect( b.getAttribute( 'foo' ) ).to.equal( 'bar' );
+			expect( b.getAttribute( 'baz' ) ).to.equal( 'qux' );
+			expect( b.getAttribute( 'one' ) ).to.equal( 'two' );
+
+			test( writer, newRange, p, {
+				instanceOf: ContainerElement,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{ instanceOf: AttributeElement, name: 'b', children: [] }
+				]
+			} );
+		} );
+
+		it( 'should not merge attributes when they differ', () => {
+			// <p>[<b foo="bar" ></b>]</p>
+			// wrap with <b foo="baz"></b>
+			// <p>[<b foo="baz"><b foo="bar"></b></b>]</p>
+			const b = new AttributeElement( 'b', {
+				foo: 'bar'
+			} );
+			const p = new ContainerElement( 'p', null, b );
+			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
+			const wrapper = new AttributeElement( 'b', {
+				foo: 'baz'
+			} );
+
+			const newRange = writer.wrap( range, wrapper );
+			expect( b.getAttribute( 'foo' ) ).to.equal( 'bar' );
+			expect( b.parent.isSimilar( wrapper ) ).to.be.true;
+			expect( b.parent.getAttribute( 'foo' ) ).to.equal( 'baz' );
+
+			test( writer, newRange, p, {
+				instanceOf: ContainerElement,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{ instanceOf: AttributeElement, name: 'b', children: [
+						{ instanceOf: AttributeElement, name: 'b', children: [] }
+					] }
+				]
+			} );
+		} );
+
+		it( 'should wrap single element by merging classes', () => {
+			// <p>[<b class="foo bar baz" ></b>]</p>
+			// wrap with <b class="foo bar qux jax"></b>
+			// <p>[<b class="foo bar baz qux jax"></b>]</p>
+			const b = new AttributeElement( 'b', {
+				class: 'foo bar baz'
+			} );
+			const p = new ContainerElement( 'p', null, b );
+			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
+			const wrapper = new AttributeElement( 'b', {
+				class: 'foo bar qux jax'
+			} );
+
+			const newRange = writer.wrap( range, wrapper );
+			expect( b.hasClass( 'foo', 'bar', 'baz', 'qux', 'jax' ) ).to.be.true;
+			test( writer, newRange, p, {
+				instanceOf: ContainerElement,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{ instanceOf: AttributeElement, name: 'b', children: [] }
+				]
+			} );
+		} );
+
+		it( 'should wrap single element by merging styles', () => {
+			// <p>[<b style="color:red; position: absolute;"></b>]</p>
+			// wrap with <b style="color:red; top: 20px;"></b>
+			// <p>[<b class="color:red; position: absolute; top:20px;"></b>]</p>
+			const b = new AttributeElement( 'b', {
+				style: 'color: red; position: absolute;'
+			} );
+			const p = new ContainerElement( 'p', null, b );
+			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
+			const wrapper = new AttributeElement( 'b', {
+				style: 'color:red; top: 20px;'
+			} );
+
+			const newRange = writer.wrap( range, wrapper );
+			expect( b.getStyle( 'color' ) ).to.equal( 'red' );
+			expect( b.getStyle( 'position' ) ).to.equal( 'absolute' );
+			expect( b.getStyle( 'top' ) ).to.equal( '20px' );
+
+			test( writer, newRange, p, {
+				instanceOf: ContainerElement,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{ instanceOf: AttributeElement, name: 'b', children: [] }
+				]
+			} );
+		} );
+
+		it( 'should not merge styles when they differ', () => {
+			// <p>[<b style="color:red;"></b>]</p>
+			// wrap with <b style="color:black;"></b>
+			// <p>[<b style="color:black;"><b style="color:red;"></b></b>]</p>
+			const b = new AttributeElement( 'b', {
+				style: 'color:red'
+			} );
+			const p = new ContainerElement( 'p', null, b );
+			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
+			const wrapper = new AttributeElement( 'b', {
+				style: 'color:black'
+			} );
+
+			const newRange = writer.wrap( range, wrapper );
+			expect( b.getStyle( 'color' ) ).to.equal( 'red' );
+			expect( b.parent.isSimilar( wrapper ) ).to.be.true;
+			expect( b.parent.getStyle( 'color' ) ).to.equal( 'black' );
+
+			test( writer, newRange, p, {
+				instanceOf: ContainerElement,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{ instanceOf: AttributeElement, name: 'b', children: [
+						{ instanceOf: AttributeElement, name: 'b', children: [] }
+					] }
+				]
+			} );
+		} );
+
+		it( 'should not merge single elements when they have different priority', () => {
+			// <p>[<b style="color:red;"></b>]</p>
+			// wrap with <b style="color:red;"></b> with different priority
+			// <p>[<b style="color:red;"><b style="color:red;"></b></b>]</p>
+			const b = new AttributeElement( 'b', {
+				style: 'color:red'
+			} );
+			const p = new ContainerElement( 'p', null, b );
+			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
+			const wrapper = new AttributeElement( 'b', {
+				style: 'color:red'
+			} );
+			wrapper.priority = b.priority - 1;
+
+			const newRange = writer.wrap( range, wrapper );
+			expect( b.getStyle( 'color' ) ).to.equal( 'red' );
+			expect( b.parent.isSimilar( wrapper ) ).to.be.true;
+			expect( b.parent.getStyle( 'color' ) ).to.equal( 'red' );
+
+			test( writer, newRange, p, {
+				instanceOf: ContainerElement,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{ instanceOf: AttributeElement, name: 'b', children: [
+						{ instanceOf: AttributeElement, name: 'b', children: [] }
+					] }
+				]
+			} );
+		} );
+
+		it( 'should be merged with outside element when wrapping all children', () => {
+			// <p><b foo="bar">[{foobar}<i>{baz}</i>]</b></p>
+			// wrap with <b baz="qux"></b>
+			// <p>[<b foo="bar" baz="qux">{foobar}</b>]</p>
+			const text1 = new Text( 'foobar' );
+			const text2 = new Text( 'baz' );
+			const i = new AttributeElement( 'i', null, text2 );
+			const b = new AttributeElement( 'b', { foo: 'bar' }, [ text1, i ] );
+			const p = new ContainerElement( 'p', null, [ b ] );
+			const wrapper = new AttributeElement( 'b', { baz: 'qux' } );
+			const range = Range.createFromParentsAndOffsets( b, 0, b, 2 );
+
+			const newRange = writer.wrap( range, wrapper );
+			expect( b.getAttribute( 'foo' ) ).to.equal( 'bar' );
+			expect( b.getAttribute( 'baz' ) ).to.equal( 'qux' );
+			test( writer, newRange, p, {
+				instanceOf: ContainerElement,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceof: AttributeElement,
+						name: 'b',
+						children: [
+							{ instanceOf: Text, data: 'foobar' },
+							{
+								instanceOf: AttributeElement,
+								name: 'i',
+								children: [
+									{ instanceOf: Text, data: 'baz' }
+								]
+							}
+						]
+					}
+				]
+			} );
+		} );
 	} );
 } );