Explorar el Código

(Temp) Improved Template#apply() so it preserves original data. Implemented Template#revert()

Aleksander Nowodzinski hace 9 años
padre
commit
8faf3d14ed
Se han modificado 2 ficheros con 779 adiciones y 55 borrados
  1. 202 36
      packages/ckeditor5-ui/src/template.js
  2. 577 19
      packages/ckeditor5-ui/tests/template.js

+ 202 - 36
packages/ckeditor5-ui/src/template.js

@@ -55,6 +55,13 @@ export default class Template {
 	constructor( def ) {
 		Object.assign( this, normalize( clone( def ) ) );
 
+		/**
+		 * A template used by {@link #revert} method.
+		 *
+		 * @member {ui/template~Template}
+		 */
+		this._revertData = null;
+
 		/**
 		 * Tag of this template, i.e. `div`, indicating that the instance will render
 		 * to an HTMLElement.
@@ -129,6 +136,7 @@ export default class Template {
 	 *		element.outerHTML == "<div id="first-div" class="my-div">Div text.</div>"
 	 *
 	 * @see module:ui/template~Template#render
+	 * @see module:ui/template~Template#revert
 	 * @param {Node} element Root element for the template to apply.
 	 */
 	apply( node ) {
@@ -141,7 +149,39 @@ export default class Template {
 			throw new CKEditorError( 'ui-template-wrong-node: No DOM Node specified.' );
 		}
 
-		return this._renderNode( node );
+		this._revertData = {};
+
+		this._renderNode( node, null, this._revertData );
+
+		return node;
+	}
+
+	/**
+	 * Reverts the template from an existing DOM Node, either `HTMLElement` or `Text`.
+	 *
+	 * @see module:ui/template~Template#apply
+	 * @param {Node} element Root element for the template to revert.
+	 */
+	revert( node ) {
+		if ( !node ) {
+			/**
+			 * No DOM Node specified.
+			 *
+			 * @error ui-template-wrong-syntax
+			 */
+			throw new CKEditorError( 'ui-template-wrong-node: No DOM Node specified.' );
+		}
+
+		if ( !this._revertData ) {
+			/**
+			 * Attempting reverting a template which has not been applied yet.
+			 *
+			 * @error ui-template-revert-not-applied
+			 */
+			throw new CKEditorError( 'ui-template-revert-not-applied: Attempting reverting a template which has not been applied yet.' );
+		}
+
+		this._revertNode( node, this._revertData );
 	}
 
 	/**
@@ -242,8 +282,9 @@ export default class Template {
 	 * @param {Node} applyNode If specified, this template will be applied to an existing DOM Node.
 	 * @param {Boolean} intoFragment If set, children are rendered into `DocumentFragment`.
 	 * @returns {HTMLElement|Text} A rendered Node.
+	 * @param {Object} revertData
 	 */
-	_renderNode( applyNode, intoFragment ) {
+	_renderNode( applyNode, intoFragment, revertData ) {
 		let isInvalid;
 
 		if ( applyNode ) {
@@ -264,7 +305,10 @@ export default class Template {
 			throw new CKEditorError( 'ui-template-wrong-syntax: Node definition must have either "tag" or "text" when rendering new Node.' );
 		}
 
-		return this.text ? this._renderText( applyNode ) : this._renderElement( applyNode, intoFragment );
+		return this.text ?
+				this._renderText( applyNode, !!applyNode, revertData )
+			:
+				this._renderElement( applyNode, intoFragment, revertData );
 	}
 
 	/**
@@ -274,12 +318,13 @@ export default class Template {
 	 * @param {HTMLElement} applyElement If specified, this template will be applied to an existing `HTMLElement`.
 	 * @param {Boolean} intoFragment If set, children are rendered into `DocumentFragment`.
 	 * @returns {HTMLElement} A rendered `HTMLElement`.
+	 * @param {Object} revertData
 	 */
-	_renderElement( applyElement, intoFragment ) {
+	_renderElement( applyElement, intoFragment, revertData ) {
 		const el = applyElement ||
 			document.createElementNS( this.ns || xhtmlNs, this.tag );
 
-		this._renderAttributes( el );
+		this._renderAttributes( el, !!applyElement, revertData );
 
 		// Invoke children recursively.
 		if ( intoFragment ) {
@@ -289,11 +334,11 @@ export default class Template {
 
 			el.appendChild( docFragment );
 		} else {
-			this._renderElementChildren( el, el, !!applyElement );
+			this._renderElementChildren( el, el, !!applyElement, revertData );
 		}
 
 		// Setup DOM bindings event listeners.
-		this._setUpListeners( el );
+		this._setUpListeners( el, revertData );
 
 		return el;
 	}
@@ -304,20 +349,40 @@ export default class Template {
 	 * @protected
 	 * @param {HTMLElement} textNode If specified, this template instance will be applied to an existing `Text` Node.
 	 * @returns {Text} A rendered `Text` node in DOM.
+	 * @param {Object} revertData
 	 */
-	_renderText( textNode = document.createTextNode( '' ) ) {
+	_renderText( textNode, shouldApply, revertData ) {
+		if ( !textNode ) {
+			textNode = document.createTextNode( '' );
+		}
+
+		// Save the original textContent to revert it in #revert().
+		if ( shouldApply ) {
+			revertData.text = textNode.textContent;
+		}
+
 		// Check if this Text Node is bound to Observable. Cases:
 		//		{ text: [ Template.bind( ... ).to( ... ) ] }
 		//		{ text: [ 'foo', Template.bind( ... ).to( ... ), ... ] }
 		if ( hasTemplateBinding( this.text ) ) {
-			this._bindToObservable( this.text, textNode, getTextUpdater( textNode ) );
+			// Preserve the original content of the text node.
+			if ( shouldApply && textNode.textContent ) {
+				this.text.unshift( textNode.textContent );
+			}
+
+			this._bindToObservable(
+				this.text,
+				textNode,
+				getTextUpdater( textNode ),
+				revertData
+			);
 		}
 
 		// Simply set text. Cases:
 		// 		{ text: [ 'all', 'are', 'static' ] }
 		// 		{ text: [ 'foo' ] }
 		else {
-			textNode.textContent = this.text.join( '' );
+			textNode.textContent += this.text.join( '' );
 		}
 
 		return textNode;
@@ -328,17 +393,28 @@ export default class Template {
 	 *
 	 * @protected
 	 * @param {HTMLElement} el `HTMLElement` which attributes are to be rendered.
+	 * @param {Object} revertData
 	 */
-	_renderAttributes( el ) {
-		let attrName, attrValue, attrNs;
+	_renderAttributes( el, shouldApply, revertData ) {
+		let attrName, attrValue, domValue, attrNs;
 
 		if ( !this.attributes ) {
 			return;
 		}
 
+		if ( shouldApply ) {
+			revertData.attributes = {};
+		}
+
 		for ( attrName in this.attributes ) {
+			domValue = el.getAttribute( attrName );
 			attrValue = this.attributes[ attrName ];
 
+			// Save revert data.
+			if ( shouldApply ) {
+				revertData.attributes[ attrName ] = domValue;
+			}
+
 			// Detect custom namespace:
 			// 		{ class: { ns: 'abc', value: Template.bind( ... ).to( ... ) } }
 			attrNs = isObject( attrValue[ 0 ] ) && attrValue[ 0 ].ns ? attrValue[ 0 ].ns : null;
@@ -348,12 +424,20 @@ export default class Template {
 			// 		{ class: [ 'bar', Template.bind( ... ).to( ... ), 'baz' ] }
 			// 		{ class: { ns: 'abc', value: Template.bind( ... ).to( ... ) } }
 			if ( hasTemplateBinding( attrValue ) ) {
+				// Normalize attributes with additional data like namespace:
+				//		{ class: { ns: 'abc', value: [ ... ] } }
+				const attrValueToBind = attrNs ? attrValue[ 0 ].value : attrValue;
+
+				if ( shouldApply ) {
+					// Preserve the original value.
+					attrValueToBind.unshift( domValue );
+				}
+
 				this._bindToObservable(
-					// Normalize attributes with additional data like namespace:
-					//		{ class: { ns: 'abc', value: [ ... ] } }
-					attrNs ? attrValue[ 0 ].value : attrValue,
+					attrValueToBind,
 					el,
-					getAttributeUpdater( el, attrName, attrNs )
+					getAttributeUpdater( el, attrName, attrNs ),
+					revertData
 				);
 			}
 
@@ -363,7 +447,7 @@ export default class Template {
 			//			height: Template.bind( ... ).to( ... )
 			//		}
 			else if ( attrName == 'style' && typeof attrValue[ 0 ] !== 'string' ) {
-				this._renderStyleAttribute( attrValue[ 0 ], el );
+				this._renderStyleAttribute( attrValue[ 0 ], el, revertData );
 			}
 
 			// Otherwise simply set the static attribute.
@@ -371,6 +455,10 @@ export default class Template {
 			// 		{ class: [ 'all', 'are', 'static' ] }
 			// 		{ class: [ { ns: 'abc', value: [ 'foo' ] } ] }
 			else {
+				if ( domValue ) {
+					attrValue.unshift( domValue );
+				}
+
 				attrValue = attrValue
 					// Retrieve "values" from { class: [ { ns: 'abc', value: [ ... ] } ] }
 					.map( v => v ? ( v.value || v ) : v )
@@ -411,8 +499,9 @@ export default class Template {
 	 * @private
 	 * @param {Object} styles module:ui/template~TemplateDefinition.attributes.styles Styles definition.
 	 * @param {HTMLElement} el `HTMLElement` which `style` attribute is rendered.
+	 * @param {Object} revertData
 	 */
-	_renderStyleAttribute( styles, el ) {
+	_renderStyleAttribute( styles, el, revertData ) {
 		for ( let styleName in styles ) {
 			const styleValue = styles[ styleName ];
 
@@ -420,7 +509,12 @@ export default class Template {
 			//	color: bind.to( 'attribute' )
 			// }
 			if ( hasTemplateBinding( styleValue ) ) {
-				this._bindToObservable( [ styleValue ], el, getStyleUpdater( el, styleName ) );
+				this._bindToObservable(
+					[ styleValue ],
+					el,
+					getStyleUpdater( el, styleName ),
+					revertData
+				);
 			}
 
 			// style: {
@@ -440,8 +534,9 @@ export default class Template {
 	 * @param {HTMLElement|DocumentFragment} container `HTMLElement` or `DocumentFragment`
 	 * into which children are being rendered. If `shouldApply == true`, then `container === element`.
 	 * @param {Boolean} shouldApply Traverse existing DOM structure only, don't modify DOM.
+	 * @param {Object} revertData
 	 */
-	_renderElementChildren( element, container, shouldApply ) {
+	_renderElementChildren( element, container, shouldApply, revertData ) {
 		let childIndex = 0;
 
 		for ( let child of this.children ) {
@@ -459,7 +554,15 @@ export default class Template {
 				}
 			} else {
 				if ( shouldApply ) {
-					child._renderNode( container.childNodes[ childIndex++ ] );
+					const childRevertData = {};
+
+					if ( !revertData.children ) {
+						revertData.children = [];
+					}
+
+					revertData.children.push( childRevertData );
+
+					child._renderNode( container.childNodes[ childIndex++ ], null, childRevertData );
 				} else {
 					container.appendChild( child.render() );
 				}
@@ -472,18 +575,24 @@ export default class Template {
 	 *
 	 * @protected
 	 * @param {HTMLElement} el `HTMLElement` which is being rendered.
+	 * @param {Object} revertData
 	 */
-	_setUpListeners( el ) {
+	_setUpListeners( el, revertData ) {
 		if ( !this.eventListeners ) {
 			return;
 		}
 
+		if ( revertData && !revertData.bindings ) {
+			revertData.bindings = [];
+		}
+
 		for ( let key in this.eventListeners ) {
-			const [ domEvtName, domSelector ] = key.split( '@' );
+			const reverts = this.eventListeners[ key ]
+				.map( schemaItem => schemaItem.activateDomEventListener( el, ...key.split( '@' ) ) );
 
-			this.eventListeners[ key ].forEach( schemaItem => {
-				schemaItem.activateDomEventListener( el, domEvtName, domSelector );
-			} );
+			if ( revertData ) {
+				revertData.bindings.push( reverts );
+			}
 		}
 	}
 
@@ -497,9 +606,13 @@ export default class Template {
 	 * @param {module:ui/template~TemplateValueSchema} valueSchema
 	 * @param {Node} node DOM Node to be updated when {@link module:utils/observablemixin~ObservableMixin} changes.
 	 * @param {Function} domUpdater A function which updates DOM (like attribute or text).
+	 * @param {Object} revertData
 	 */
-	_bindToObservable( valueSchema ) {
-		valueSchema
+	_bindToObservable( valueSchema, node, domUpdater, revertData ) {
+		// Set initial values.
+		syncValueSchemaValue( ...arguments );
+
+		const reverts = valueSchema
 			// Filter "falsy" (false, undefined, null, '') value schema components out.
 			.filter( item => !isFalsy( item ) )
 			// Filter inactive bindings from schema, like static strings ('foo'), numbers (42), etc.
@@ -507,10 +620,49 @@ export default class Template {
 			// Once only the actual binding are left, let the emitter listen to observable change:attribute event.
 			// TODO: Reduce the number of listeners attached as many bindings may listen
 			// to the same observable attribute.
-			.forEach( templateBinding => templateBinding.activateAttributeListener( ...arguments ) );
+			.map( templateBinding => templateBinding.activateAttributeListener( ...arguments ) );
 
-		// Set initial values.
-		syncValueSchemaValue( ...arguments );
+		if ( revertData ) {
+			if ( !revertData.bindings ) {
+				revertData.bindings = [];
+			}
+
+			revertData.bindings.push( reverts );
+		}
+	}
+
+	_revertNode( node, revertData ) {
+		if ( revertData.text ) {
+			node.textContent = revertData.text;
+		}
+
+		for ( let attrName in revertData.attributes ) {
+			const attrValue = revertData.attributes[ attrName ];
+
+			// When the attribute has **not** been set before #apply().
+			if ( attrValue === null ) {
+				node.removeAttribute( attrName );
+			} else {
+				node.setAttribute( attrName, attrValue );
+			}
+		}
+
+		if ( revertData.bindings ) {
+			for ( let binding of revertData.bindings ) {
+				// Each binding may consist of several observable+observable#attribute.
+				// like the following has 2:
+				// 		class: [ 'x', bind.to( 'foo' ), 'y', bind.to( 'bar' ) ]
+				for ( let revertBinding of binding ) {
+					revertBinding();
+				}
+			}
+		}
+
+		if ( revertData.children ) {
+			for ( let i = 0; i < revertData.children.length; ++i ) {
+				this._revertNode( node.childNodes[ i ], revertData.children[ i ] );
+			}
+		}
 	}
 }
 
@@ -584,11 +736,17 @@ export class TemplateBinding {
 	 * @param {module:ui/template~TemplateValueSchema} valueSchema A full schema to generate an attribute or text in DOM.
 	 * @param {Node} node A native DOM node, which attribute or text is to be updated.
 	 * @param {Function} updater A DOM updater function used to update native DOM attribute or text.
+	 * @returns {Function} TODO
 	 */
 	activateAttributeListener( valueSchema, node, updater ) {
-		this.emitter.listenTo( this.observable, 'change:' + this.attribute, () => {
-			syncValueSchemaValue( valueSchema, node, updater );
-		} );
+		const callback = () => syncValueSchemaValue( valueSchema, node, updater );
+
+		this.emitter.listenTo( this.observable, 'change:' + this.attribute, callback );
+
+		// Allows revert of the listener.
+		return () => {
+			this.emitter.stopListening( this.observable, 'change:' + this.attribute, callback );
+		};
 	}
 }
 
@@ -610,9 +768,10 @@ export class TemplateToBinding extends TemplateBinding {
 	 * @param {HTMLElement} element An element on which listening to the native DOM event.
 	 * @param {String} domEvtName A name of the native DOM event.
 	 * @param {String} [domSelector] A selector in DOM to filter delegated events.
+	 * @returns {Function} TODO
 	 */
 	activateDomEventListener( el, domEvtName, domSelector ) {
-		this.emitter.listenTo( el, domEvtName, ( evt, domEvt ) => {
+		const callback = ( evt, domEvt ) => {
 			if ( !domSelector || domEvt.target.matches( domSelector ) ) {
 				if ( typeof this.eventNameOrFunction == 'function' ) {
 					this.eventNameOrFunction( domEvt );
@@ -620,7 +779,14 @@ export class TemplateToBinding extends TemplateBinding {
 					this.observable.fire( this.eventNameOrFunction, domEvt );
 				}
 			}
-		} );
+		};
+
+		this.emitter.listenTo( el, domEvtName, callback );
+
+		// Allows revert of the listener.
+		return () => {
+			this.emitter.stopListening( el, domEvtName, callback );
+		};
 	}
 }
 

+ 577 - 19
packages/ckeditor5-ui/tests/template.js

@@ -119,7 +119,7 @@ describe( 'Template', () => {
 		} );
 	} );
 
-	describe( 'render', () => {
+	describe( 'render()', () => {
 		it( 'throws when the template definition is wrong', () => {
 			expect( () => {
 				new Template( {} ).render();
@@ -609,7 +609,7 @@ describe( 'Template', () => {
 		} );
 	} );
 
-	describe( 'apply', () => {
+	describe( 'apply()', () => {
 		let observable, domEmitter, bind;
 
 		beforeEach( () => {
@@ -659,7 +659,21 @@ describe( 'Template', () => {
 				expect( text.textContent ).to.equal( 'abc' );
 			} );
 
-			it( 'applies new textContent to an existing Text Node of an HTMLElement', () => {
+			it( 'preserves existing textContent of a Text Node', () => {
+				text.textContent = 'foo';
+
+				new Template( {
+					text: bind.to( 'foo' )
+				} ).apply( text );
+
+				expect( text.textContent ).to.equal( 'foo bar' );
+
+				observable.foo = 'qux';
+
+				expect( text.textContent ).to.equal( 'foo qux' );
+			} );
+
+			it( 'preserves textContent of an existing Text Node in a HTMLElement', () => {
 				el.textContent = 'bar';
 
 				new Template( {
@@ -667,7 +681,7 @@ describe( 'Template', () => {
 					children: [ 'foo' ]
 				} ).apply( el );
 
-				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div>foo</div>' );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div>barfoo</div>' );
 			} );
 		} );
 
@@ -684,6 +698,23 @@ describe( 'Template', () => {
 				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div class="a b" x="bar"></div>' );
 			} );
 
+			it( 'preserves existing attributes', () => {
+				el.setAttribute( 'class', 'default' );
+				el.setAttribute( 'x', 'foo' );
+
+				new Template( {
+					tag: 'div',
+					attributes: {
+						'class': [ 'a', 'b' ],
+						x: 'bar'
+					}
+				} ).apply( el );
+
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<div class="default a b" x="foo bar"></div>'
+				);
+			} );
+
 			it( 'applies attributes and TextContent to a DOM tree', () => {
 				el.textContent = 'abc';
 				el.appendChild( document.createElement( 'span' ) );
@@ -704,7 +735,108 @@ describe( 'Template', () => {
 					]
 				} ).apply( el );
 
-				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div class="parent">Children:<span class="child"></span></div>' );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div class="parent">abcChildren:<span class="child"></span></div>' );
+			} );
+
+			describe( 'style', () => {
+				beforeEach( () => {
+					observable = new Model( {
+						width: '10px',
+						backgroundColor: 'yellow'
+					} );
+
+					bind = Template.bind( observable, domEmitter );
+				} );
+
+				it( 'applies as a static value', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							style: 'color: red;'
+						}
+					} );
+
+					new Template( {
+						attributes: {
+							style: 'display: block'
+						}
+					} ).apply( el );
+
+					expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="color:red;display:block;"></p>' );
+				} );
+
+				it( 'applies as a static value (Array of values)', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							style: [ 'color: red;', 'display: block;' ]
+						}
+					} );
+
+					new Template( {
+						attributes: {
+							style: [ 'float: left;', 'overflow: hidden;' ]
+						}
+					} ).apply( el );
+
+					expect( normalizeHtml( el.outerHTML ) ).to.equal(
+						'<p style="color:red;display:block;float:left;overflow:hidden;"></p>'
+					);
+				} );
+
+				it( 'applies when in an object syntax', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							style: {
+								width: '20px',
+							}
+						}
+					} );
+
+					new Template( {
+						attributes: {
+							style: {
+								height: '10px',
+								float: 'left',
+								backgroundColor: 'green'
+							}
+						}
+					} ).apply( el );
+
+					expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="width:20px;height:10px;float:left;background-color:green;"></p>' );
+				} );
+
+				it( 'applies when bound to observable', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							style: {
+								left: '20px',
+							}
+						}
+					} );
+
+					new Template( {
+						attributes: {
+							style: {
+								width: bind.to( 'width' ),
+								float: 'left',
+								backgroundColor: 'green'
+							}
+						}
+					} ).apply( el );
+
+					expect( normalizeHtml( el.outerHTML ) ).to.equal(
+						'<p style="left:20px;width:10px;float:left;background-color:green;"></p>'
+					);
+
+					observable.width = '100px';
+
+					expect( normalizeHtml( el.outerHTML ) ).to.equal(
+						'<p style="left:20px;width:100px;float:left;background-color:green;"></p>'
+					);
+				} );
 			} );
 		} );
 
@@ -770,17 +902,22 @@ describe( 'Template', () => {
 				expect( collection._parentElement ).to.be.null;
 			} );
 
-			it( 'should work for deep DOM structure', () => {
+			it( 'should work for deep DOM structure with bindings and event listeners', () => {
 				const childA = document.createElement( 'a' );
 				const childB = document.createElement( 'b' );
 
-				childA.textContent = 'anchor';
-				childB.textContent = 'bold';
+				childA.textContent = 'a';
+				childB.textContent = 'b';
+
+				childA.setAttribute( 'class', 'a1 a2' );
+				childB.setAttribute( 'class', 'b1 b2' );
 
 				el.appendChild( childA );
 				el.appendChild( childB );
 
-				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div><a>anchor</a><b>bold</b></div>' );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<div><a class="a1 a2">a</a><b class="b1 b2">b</b></div>'
+				);
 
 				const spy1 = testUtils.sinon.spy();
 				const spy2 = testUtils.sinon.spy();
@@ -802,7 +939,7 @@ describe( 'Template', () => {
 								class: bind.to( 'foo', val => 'applied-A-' + val ),
 								id: 'applied-A'
 							},
-							children: [ 'Text applied to childA.' ]
+							children: [ ', applied-a' ]
 						},
 						{
 							tag: 'b',
@@ -813,7 +950,7 @@ describe( 'Template', () => {
 								class: bind.to( 'baz', val => 'applied-B-' + val ),
 								id: 'applied-B'
 							},
-							children: [ 'Text applied to childB.' ]
+							children: [ ', applied-b' ]
 						},
 						'Text which is not to be applied because it does NOT exist in original element.'
 					],
@@ -827,15 +964,15 @@ describe( 'Template', () => {
 				} ).apply( el );
 
 				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div class="applied-parent-qux" id="BAR">' +
-					'<a class="applied-A-bar" id="applied-A">Text applied to childA.</a>' +
-					'<b class="applied-B-qux" id="applied-B">Text applied to childB.</b>' +
+					'<a class="a1 a2 applied-A-bar" id="applied-A">a, applied-a</a>' +
+					'<b class="b1 b2 applied-B-qux" id="applied-B">b, applied-b</b>' +
 				'</div>' );
 
 				observable.foo = 'updated';
 
 				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div class="applied-parent-qux" id="UPDATED">' +
-					'<a class="applied-A-updated" id="applied-A">Text applied to childA.</a>' +
-					'<b class="applied-B-qux" id="applied-B">Text applied to childB.</b>' +
+					'<a class="a1 a2 applied-A-updated" id="applied-A">a, applied-a</a>' +
+					'<b class="b1 b2 applied-B-qux" id="applied-B">b, applied-b</b>' +
 				'</div>' );
 
 				document.body.appendChild( el );
@@ -857,7 +994,424 @@ describe( 'Template', () => {
 		} );
 	} );
 
-	describe( 'bind', () => {
+	describe( 'revert()', () => {
+		let observable, domEmitter, bind;
+
+		beforeEach( () => {
+			el = document.createElement( 'div' );
+
+			observable = new Model( {
+				foo: 'bar',
+				baz: 'qux'
+			} );
+
+			domEmitter = Object.create( DomEmitterMixin );
+			bind = Template.bind( observable, domEmitter );
+		} );
+
+		it( 'throws when no HTMLElement passed', () => {
+			expect( () => {
+				new Template( {
+					tag: 'p'
+				} ).revert();
+			} ).to.throw( CKEditorError, /ui-template-wrong-node/ );
+		} );
+
+		it( 'should throw if template is not applied', () => {
+			const tpl = new Template( {
+				tag: 'div'
+			} );
+
+			expect( () => {
+				tpl.revert( el );
+			} ).to.throw( CKEditorError, /ui-template-revert-not-applied/ );
+
+			tpl.render();
+
+			expect( () => {
+				tpl.revert( el );
+			} ).to.throw( CKEditorError, /ui-template-revert-not-applied/ );
+		} );
+
+		describe( 'text', () => {
+			it( 'should revert textContent to the initial value', () => {
+				el = getElement( {
+					tag: 'a',
+					children: [
+						'a',
+						{
+							tag: 'b',
+							children: [
+								'b'
+							]
+						}
+					]
+				} );
+
+				const tpl = new Template( {
+					children: [
+						'bar',
+						{
+							children: [
+								'qux'
+							]
+						}
+					]
+				} );
+
+				tpl.apply( el );
+
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a>abar<b>bqux</b></a>'
+				);
+
+				tpl.revert( el );
+
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a>a<b>b</b></a>'
+				);
+			} );
+
+			it( 'should remove bindings', () => {
+				el = getElement( {
+					tag: 'a',
+					children: [
+						'a',
+						{
+							tag: 'b',
+							children: [
+								'b'
+							]
+						}
+					]
+				} );
+
+				const tpl = new Template( {
+					children: [
+						'foo',
+						{
+							children: [
+								{
+									text: bind.to( 'foo' )
+								}
+							]
+						}
+					]
+				} );
+
+				tpl.apply( el );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a>afoo<b>b bar</b></a>'
+				);
+
+				observable.foo = 'abc';
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a>afoo<b>b abc</b></a>'
+				);
+
+				tpl.revert( el );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a>a<b>b</b></a>'
+				);
+
+				observable.foo = 'xyz';
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a>a<b>b</b></a>'
+				);
+			} );
+		} );
+
+		describe( 'attributes', () => {
+			it( 'should revert attributes to the initial values', () => {
+				el = getElement( {
+					tag: 'a',
+					attributes: {
+						foo: 'af',
+						bar: 'ab',
+					},
+					children: [
+						{
+							tag: 'b',
+							attributes: {
+								foo: 'bf',
+								bar: 'bb',
+							}
+						}
+					]
+				} );
+
+				const tpl = new Template( {
+					attributes: {
+						foo: 'af1',
+						bar: [ 'ab1', 'ab2' ],
+						baz: 'x'
+					},
+					children: [
+						{
+							attributes: {
+								foo: 'bf1'
+							}
+						}
+					]
+				} );
+
+				tpl.apply( el );
+
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a bar="ab ab1 ab2" baz="x" foo="af af1">' +
+						'<b bar="bb" foo="bf bf1"></b>' +
+					'</a>'
+				);
+
+				tpl.revert( el );
+
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a bar="ab" foo="af">' +
+						'<b bar="bb" foo="bf"></b>' +
+					'</a>'
+				);
+			} );
+
+			it( 'should remove bindings', () => {
+				el = getElement( {
+					tag: 'a',
+					attributes: {
+						foo: 'af',
+						bar: 'ab',
+					},
+					children: [
+						{
+							tag: 'b',
+							attributes: {
+								foo: 'bf',
+								bar: 'bb',
+							}
+						}
+					]
+				} );
+
+				const tpl = new Template( {
+					attributes: {
+						foo: 'af1',
+						bar: [
+							'ab1',
+							bind.to( 'baz' )
+						]
+					},
+					children: [
+						{
+							attributes: {
+								foo: bind.to( 'foo' )
+							}
+						}
+					]
+				} );
+
+				tpl.apply( el );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a bar="ab ab1 qux" foo="af af1">' +
+						'<b bar="bb" foo="bf bar"></b>' +
+					'</a>'
+				);
+
+				observable.foo = 'x';
+				observable.baz = 'y';
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a bar="ab ab1 y" foo="af af1">' +
+						'<b bar="bb" foo="bf x"></b>' +
+					'</a>'
+				);
+
+				tpl.revert( el );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a bar="ab" foo="af">' +
+						'<b bar="bb" foo="bf"></b>' +
+					'</a>'
+				);
+
+				observable.foo = 'abc';
+				observable.baz = 'cba';
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a bar="ab" foo="af">' +
+						'<b bar="bb" foo="bf"></b>' +
+					'</a>'
+				);
+			} );
+
+			describe( 'style', () => {
+				beforeEach( () => {
+					observable = new Model( {
+						overflow: 'visible'
+					} );
+
+					bind = Template.bind( observable, domEmitter );
+				} );
+
+				it( 'should remove bindings', () => {
+					el = getElement( {
+						tag: 'a',
+						attributes: {
+							style: {
+								fontWeight: 'bold'
+							}
+						},
+						children: [
+							{
+								tag: 'b',
+								attributes: {
+									style: {
+										color: 'red'
+									}
+								}
+							}
+						]
+					} );
+
+					const tpl = new Template( {
+						attributes: {
+							style: {
+								overflow: bind.to( 'overflow' )
+							}
+						},
+						children: [
+							{
+								tag: 'b',
+								attributes: {
+									style: {
+										display: 'block'
+									}
+								}
+							}
+						]
+					} );
+
+					tpl.apply( el );
+					expect( normalizeHtml( el.outerHTML ) ).to.equal(
+						'<a style="font-weight:bold;overflow:visible;">' +
+							'<b style="color:red;display:block;"></b>' +
+						'</a>'
+					);
+
+					tpl.revert( el );
+					expect( normalizeHtml( el.outerHTML ) ).to.equal(
+						'<a style="font-weight:bold;">' +
+							'<b style="color:red;"></b>' +
+						'</a>'
+					);
+
+					observable.overflow = 'hidden';
+					expect( normalizeHtml( el.outerHTML ) ).to.equal(
+						'<a style="font-weight:bold;">' +
+							'<b style="color:red;"></b>' +
+						'</a>'
+					);
+				} );
+			} );
+		} );
+
+		describe( 'children', () => {
+			it( 'should work for deep DOM structure with bindings and event listeners', () => {
+				el = getElement( {
+					tag: 'div',
+					children: [
+						{
+							tag: 'a',
+							attributes: {
+								class: [ 'a1', 'a2' ]
+							},
+							children: [
+								'a'
+							]
+						},
+						{
+							tag: 'b',
+							attributes: {
+								class: [ 'b1', 'b2' ]
+							},
+							children: [
+								'b'
+							]
+						}
+					]
+				} );
+
+				const spy = sinon.spy();
+				observable.on( 'ku', spy );
+
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<div><a class="a1 a2">a</a><b class="b1 b2">b</b></div>'
+				);
+
+				const tpl = new Template( {
+					tag: 'div',
+					attributes: {
+						class: [ 'div1', 'div2' ],
+						style: {
+							fontWeight: 'bold'
+						}
+					},
+					children: [
+						{
+							tag: 'a',
+							attributes: {
+								class: [ 'x', 'y' ],
+								'data-new-attr': 'foo'
+							},
+							children: [ ', applied-a' ]
+						},
+						{
+							tag: 'b',
+							attributes: {
+								class: [
+									'a',
+									'b',
+									bind.to( 'foo' )
+								]
+							},
+							children: [ ', applied-b' ]
+						}
+					],
+					on: {
+						keyup: bind.to( 'ku' )
+					}
+				} );
+
+				tpl.apply( el );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<div class="div1 div2" style="font-weight:bold;">' +
+						'<a class="a1 a2 x y" data-new-attr="foo">a, applied-a</a>' +
+						'<b class="b1 b2 a b bar">b, applied-b</b>' +
+					'</div>'
+				);
+
+				observable.foo = 'baz';
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<div class="div1 div2" style="font-weight:bold;">' +
+						'<a class="a1 a2 x y" data-new-attr="foo">a, applied-a</a>' +
+						'<b class="b1 b2 a b baz">b, applied-b</b>' +
+					'</div>'
+				);
+
+				dispatchEvent( el.firstChild, 'keyup' );
+				sinon.assert.calledOnce( spy );
+
+				tpl.revert( el );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<div><a class="a1 a2">a</a><b class="b1 b2">b</b></div>'
+				);
+
+				observable.foo = 'qux';
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<div><a class="a1 a2">a</a><b class="b1 b2">b</b></div>'
+				);
+
+				dispatchEvent( el.firstChild, 'keyup' );
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+	} );
+
+	describe( 'bind()', () => {
 		it( 'returns object', () => {
 			expect( Template.bind() ).to.be.an( 'object' );
 		} );
@@ -1578,15 +2132,15 @@ describe( 'Template', () => {
 					]
 				} ).apply( el );
 
-				expect( child.textContent ).to.equal( 'bar' );
+				expect( child.textContent ).to.equal( 'foo bar' );
 
 				observable.foo = 'baz';
-				expect( child.textContent ).to.equal( 'baz' );
+				expect( child.textContent ).to.equal( 'foo baz' );
 			} );
 		} );
 	} );
 
-	describe( 'extend', () => {
+	describe( 'extend()', () => {
 		let observable, emitter, bind;
 
 		beforeEach( () => {
@@ -2280,6 +2834,10 @@ describe( 'Template', () => {
 	} );
 } );
 
+function getElement( template ) {
+	return new Template( template ).render();
+}
+
 function setElement( template ) {
 	el = new Template( template ).render();
 	document.body.appendChild( el );