Explorar el Código

Merge branch 'master' into t/ckeditor5/645

Aleksander Nowodzinski hace 8 años
padre
commit
208b2e7612

+ 138 - 29
packages/ckeditor5-engine/src/model/schema.js

@@ -112,24 +112,21 @@ import Range from './range';
  * It means that you can add listeners to implement your specific rules which are not limited by the declarative
  * {@link module:engine/model/schema~SchemaItemDefinition API}.
  *
- * The block quote feature defines such a listener to disallow nested `<blockQuote>` structures:
+ * Those listeners can be added either by listening directly to the {@link ~Schema#event:checkChild} event or
+ * by using the handy {@link ~Schema#addChildCheck} method.
  *
- *		schema.on( 'checkChild', ( evt, args ) => {
- *			// The checkChild()'s params.
- *			// Note that context is automatically normalized to SchemaContext instance by a highest-priority listener.
- *			const context = args[ 0 ];
- *			const child = args[ 1 ];
+ * For instance, the block quote feature defines such a listener to disallow nested `<blockQuote>` structures:
  *
- *			// Pass the child through getDefinition() to normalize it (child can be passed in multiple formats).
- *			const childRule = schema.getDefinition( child );
+ *		schema.addChildCheck( context, childDefinition ) => {
+ *			// Note that context is automatically normalized to SchemaContext instance and
+ *			// child to its definition (SchemaCompiledItemDefinition).
  *
  *			// If checkChild() is called with a context that ends with blockQuote and blockQuote as a child
- *			// to check, make the method return false and stop the event so no other listener will override your decision.
- *			if ( childRule && childRule.name == 'blockQuote' && context.endsWith( 'blockQuote' ) ) {
- *				evt.stop();
- *				evt.return = false;
+ *			// to check, make the checkChild() method return false.
+ *			if ( context.endsWith( 'blockQuote' ) && childDefinition.name == 'blockQuote' ) {
+ *				return false;
  *			}
- *		}, { priority: 'high' } );
+ *		} );
  *
  * ## Defining attributes
  *
@@ -199,6 +196,7 @@ export default class Schema {
 
 		this.on( 'checkChild', ( evt, args ) => {
 			args[ 0 ] = new SchemaContext( args[ 0 ] );
+			args[ 1 ] = this.getDefinition( args[ 1 ] );
 		}, { priority: 'highest' } );
 	}
 
@@ -378,9 +376,8 @@ export default class Schema {
 	 * @param {module:engine/model/schema~SchemaContextDefinition} context Context in which the child will be checked.
 	 * @param {module:engine/model/node~Node|String} child The child to check.
 	 */
-	checkChild( context, child ) {
-		const def = this.getDefinition( child );
-
+	checkChild( context, def ) {
+		// Note: context and child are already normalized here to a SchemaContext and SchemaCompiledItemDefinition.
 		if ( !def ) {
 			return false;
 		}
@@ -413,6 +410,113 @@ export default class Schema {
 		return def.allowAttributes.includes( attributeName );
 	}
 
+	/**
+	 * Allows registering a callback to the {@link #checkChild} method calls.
+	 *
+	 * Callbacks allow you to implement rules which are not otherwise possible to achieve
+	 * by using the declarative API of {@link module:engine/model/schema~SchemaItemDefinition}.
+	 * For example, by using this method you can disallow elements in specific contexts.
+	 *
+	 * This method is a shorthand for using the {@link #event:checkChild} event. For even better control,
+	 * you can use that event instead.
+	 *
+	 * Example:
+	 *
+	 *		// Disallow heading1 directly inside a blockQuote.
+	 *		schema.addChildCheck( ( context, childDefinition ) => {
+	 *			if ( context.endsWith( 'blockQuote' ) && childDefinition.name == 'heading1' ) {
+	 *				return false;
+	 *			}
+	 *		} );
+	 *
+	 * Which translates to:
+	 *
+	 *		schema.on( 'checkChild', ( evt, args ) => {
+	 *			const context = args[ 0 ];
+	 *			const childDefinition = args[ 1 ];
+	 *
+	 *			if ( context.endsWith( 'blockQuote' ) && childDefinition && childDefinition.name == 'heading1' ) {
+	 *				// Prevent next listeners from being called.
+	 *				evt.stop();
+	 *				// Set the checkChild()'s return value.
+	 *				evt.return = false;
+	 *			}
+	 *		}, { priority: 'high' } );
+	 *
+	 * @param {Function} callback The callback to be called. It is called with two parameters:
+	 * {@link module:engine/model/schema~SchemaContext} (context) instance and
+	 * {@link module:engine/model/schema~SchemaCompiledItemDefinition} (child-to-check definition).
+	 * The callback may return `true/false` to override `checkChild()`'s return value. If it does not return
+	 * a boolean value, the default algorithm (or other callbacks) will define `checkChild()`'s return value.
+	 */
+	addChildCheck( callback ) {
+		this.on( 'checkChild', ( evt, [ ctx, childDef ] ) => {
+			// checkChild() was called with a non-registered child.
+			// In 99% cases such check should return false, so not to overcomplicate all callbacks
+			// don't even execute them.
+			if ( !childDef ) {
+				return;
+			}
+
+			const retValue = callback( ctx, childDef );
+
+			if ( typeof retValue == 'boolean' ) {
+				evt.stop();
+				evt.return = retValue;
+			}
+		}, { priority: 'high' } );
+	}
+
+	/**
+	 * Allows registering a callback to the {@link #checkAttribute} method calls.
+	 *
+	 * Callbacks allow you to implement rules which are not otherwise possible to achieve
+	 * by using the declarative API of {@link module:engine/model/schema~SchemaItemDefinition}.
+	 * For example, by using this method you can disallow attribute if node to which it is applied
+	 * is contained within some other element (e.g. you want to disallow `bold` on `$text` within `heading1`).
+	 *
+	 * This method is a shorthand for using the {@link #event:checkAttribute} event. For even better control,
+	 * you can use that event instead.
+	 *
+	 * Example:
+	 *
+	 *		// Disallow bold on $text inside heading1.
+	 *		schema.addChildCheck( ( context, attributeName ) => {
+	 *			if ( context.endsWith( 'heading1 $text' ) && attributeName == 'bold' ) {
+	 *				return false;
+	 *			}
+	 *		} );
+	 *
+	 * Which translates to:
+	 *
+	 *		schema.on( 'checkAttribute', ( evt, args ) => {
+	 *			const context = args[ 0 ];
+	 *			const attributeName = args[ 1 ];
+	 *
+	 *			if ( context.endsWith( 'heading1 $text' ) && attributeName == 'bold' ) {
+	 *				// Prevent next listeners from being called.
+	 *				evt.stop();
+	 *				// Set the checkAttribute()'s return value.
+	 *				evt.return = false;
+	 *			}
+	 *		}, { priority: 'high' } );
+	 *
+	 * @param {Function} callback The callback to be called. It is called with two parameters:
+	 * {@link module:engine/model/schema~SchemaContext} (context) instance and attribute name.
+	 * The callback may return `true/false` to override `checkAttribute()`'s return value. If it does not return
+	 * a boolean value, the default algorithm (or other callbacks) will define `checkAttribute()`'s return value.
+	 */
+	addAttributeCheck( callback ) {
+		this.on( 'checkAttribute', ( evt, [ ctx, attributeName ] ) => {
+			const retValue = callback( ctx, attributeName );
+
+			if ( typeof retValue == 'boolean' ) {
+				evt.stop();
+				evt.return = retValue;
+			}
+		}, { priority: 'high' } );
+	}
+
 	/**
 	 * Returns the lowest {@link module:engine/model/schema~Schema#isLimit limit element} containing the entire
 	 * selection or the root otherwise.
@@ -600,31 +704,35 @@ mix( Schema, ObservableMixin );
  * additional behavior – e.g. implementing rules which cannot be defined using the declarative
  * {@link module:engine/model/schema~SchemaItemDefinition} interface.
  *
- * The {@link #checkChild} method fires an event because it's
+ * **Note:** The {@link #addChildCheck} method is a more handy way to register callbacks. Internally,
+ * it registers a listener to this event but comes with a simpler API and it is the recommended choice
+ * in most of the cases.
+ *
+ * The {@link #checkChild} method fires an event because it is
  * {@link module:utils/observablemixin~ObservableMixin#decorate decorated} with it. Thanks to that you can
  * use this event in a various way, but the most important use case is overriding standard behaviour of the
  * `checkChild()` method. Let's see a typical listener template:
  *
  *		schema.on( 'checkChild', ( evt, args ) => {
  *			const context = args[ 0 ];
- *			const child = args[ 1 ];
+ *			const childDefinition = args[ 1 ];
  *		}, { priority: 'high' } );
  *
  * The listener is added with a `high` priority to be executed before the default method is really called. The `args` callback
  * parameter contains arguments passed to `checkChild( context, child )`. However, the `context` parameter is already
- * normalized to a {@link module:engine/model/schema~SchemaContext} instance, so you don't have to worry about
- * the various ways how `context` may be passed to `checkChild()`.
+ * normalized to a {@link module:engine/model/schema~SchemaContext} instance and `child` to a
+ * {@link module:engine/model/schema~SchemaCompiledItemDefinition} instance, so you don't have to worry about
+ * the various ways how `context` and `child` may be passed to `checkChild()`.
+ *
+ * **Note:** `childDefinition` may be `undefined` if `checkChild()` was called with a non-registered element.
  *
  * So, in order to implement a rule "disallow `heading1` in `blockQuote`" you can add such a listener:
  *
  *		schema.on( 'checkChild', ( evt, args ) => {
  *			const context = args[ 0 ];
- *			const child = args[ 1 ];
- *
- *			// Normalize child too (it can be a string or a node).
- *			const childDefinition = schema.getDefinition( child );
+ *			const childDefinition = args[ 1 ];
  *
- *			if ( context.endsWith( 'blockQuote' ) && childDefinition.name == 'heading1' ) {
+ *			if ( context.endsWith( 'blockQuote' ) && childDefinition && childDefinition.name == 'heading1' ) {
  *				// Prevent next listeners from being called.
  *				evt.stop();
  *				// Set the checkChild()'s return value.
@@ -638,10 +746,7 @@ mix( Schema, ObservableMixin );
  *
  *		schema.on( 'checkChild', ( evt, args ) => {
  *			const context = args[ 0 ];
- *			const child = args[ 1 ];
- *
- *			// Normalize child too (it can be a string or a node).
- *			const childDefinition = schema.getDefinition( child );
+ *			const childDefinition = args[ 1 ];
  *
  *			if ( context.endsWith( 'bar foo' ) && childDefinition.name == 'listItem' ) {
  *				// Prevent next listeners from being called.
@@ -660,6 +765,10 @@ mix( Schema, ObservableMixin );
  * additional behavior – e.g. implementing rules which cannot be defined using the declarative
  * {@link module:engine/model/schema~SchemaItemDefinition} interface.
  *
+ * **Note:** The {@link #addAttributeCheck} method is a more handy way to register callbacks. Internally,
+ * it registers a listener to this event but comes with a simpler API and it is the recommended choice
+ * in most of the cases.
+ *
  * The {@link #checkAttribute} method fires an event because it's
  * {@link module:utils/observablemixin~ObservableMixin#decorate decorated} with it. Thanks to that you can
  * use this event in a various way, but the most important use case is overriding standard behaviour of the

+ 20 - 0
packages/ckeditor5-engine/src/view/uielement.js

@@ -68,11 +68,31 @@ export default class UIElement extends Element {
 	/**
 	 * Renders this {@link module:engine/view/uielement~UIElement} to DOM. This method is called by
 	 * {@link module:engine/view/domconverter~DomConverter}.
+	 * Do not use inheritance to create custom rendering method, replace `render()` method instead:
+	 *
+	 *		const myUIElement = new UIElement( 'span' );
+	 *		myUIElement.render = function( domDocument ) {
+	 *			const domElement = this.toDomElement( domDocument );
+	 *			domElement.innerHTML = '<b>this is ui element</b>';
+	 *
+	 *			return domElement;
+	 *		};
 	 *
 	 * @param {Document} domDocument
 	 * @return {HTMLElement}
 	 */
 	render( domDocument ) {
+		return this.toDomElement( domDocument );
+	}
+
+	/**
+	 * Creates DOM element based on this view UIElement.
+	 * Note that each time this method is called new DOM element is created.
+	 *
+	 * @param {Document} domDocument
+	 * @returns {HTMLElement}
+	 */
+	toDomElement( domDocument ) {
 		const domElement = domDocument.createElement( this.name );
 
 		for ( const key of this.getAttributeKeys() ) {

+ 7 - 16
packages/ckeditor5-engine/tests/conversion/buildviewconverter.js

@@ -495,16 +495,11 @@ describe( 'View converter builder', () => {
 		buildViewConverter().for( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
 
 		// Disallow $root>div.
-		schema.on( 'checkChild', ( evt, args ) => {
-			const ctx = args[ 0 ];
-			const child = args[ 1 ];
-			const childRule = schema.getDefinition( child );
-
-			if ( childRule.name == 'div' && ctx.endsWith( '$root' ) ) {
-				evt.stop();
-				evt.return = false;
+		schema.addChildCheck( ( ctx, childDef ) => {
+			if ( childDef.name == 'div' && ctx.endsWith( '$root' ) ) {
+				return false;
 			}
-		}, { priority: 'high' } );
+		} );
 
 		dispatcher.on( 'element', convertToModelFragment(), { priority: 'lowest' } );
 
@@ -527,15 +522,11 @@ describe( 'View converter builder', () => {
 	// 	buildViewConverter().for( dispatcher ).fromElement( 'strong' ).toAttribute( 'bold', true );
 
 	// 	// Disallow bold in paragraph>$text.
-	// 	schema.on( 'checkAttribute', ( evt, args ) => {
-	// 		const context = args[ 0 ];
-	// 		const attributeName = args[ 1 ];
-
+	// 	schema.addAttributeCheck( ( ctx, attributeName ) => {
 	// 		if ( ctx.endsWith( 'paragraph $text' ) && attributeName == 'bold' ) {
-	// 			evt.stop();
-	// 			evt.return = false;
+	// 			return false;
 	// 		}
-	// 	}, { priority: 'high' } );
+	// 	} );
 
 	// 	dispatcher.on( 'element', convertToModelFragment(), { priority: 'lowest' } );
 

+ 4 - 9
packages/ckeditor5-engine/tests/conversion/view-to-model-converters.js

@@ -63,16 +63,11 @@ describe( 'view-to-model-converters', () => {
 		} );
 
 		it( 'should not convert text if it is wrong with schema', () => {
-			schema.on( 'checkChild', ( evt, args ) => {
-				const ctx = args[ 0 ];
-				const child = args[ 1 ];
-				const childRule = schema.getDefinition( child );
-
-				if ( childRule.name == '$text' && ctx.endsWith( '$root' ) ) {
-					evt.stop();
-					evt.return = false;
+			schema.addChildCheck( ( ctx, childDef ) => {
+				if ( childDef.name == '$text' && ctx.endsWith( '$root' ) ) {
+					return false;
 				}
-			}, { priority: 'high' } );
+			} );
 
 			const viewText = new ViewText( 'foobar' );
 			dispatcher.on( 'text', convertText() );

+ 9 - 20
packages/ckeditor5-engine/tests/manual/tickets/1088/1.js

@@ -38,42 +38,31 @@ ClassicEditor
 
 		const schema = editor.model.schema;
 
-		schema.on( 'checkAttribute', ( evt, args ) => {
-			const ctx = args[ 0 ];
-			const attributeName = args[ 1 ];
-
+		schema.addAttributeCheck( ( ctx, attributeName ) => {
 			if ( ctx.endsWith( 'heading1 $text' ) && [ 'linkHref', 'italic' ].includes( attributeName ) ) {
-				evt.stop();
-				evt.return = false;
+				return false;
 			}
 
 			if ( ctx.endsWith( 'heading2 $text' ) && attributeName == 'italic' ) {
-				evt.stop();
-				evt.return = false;
+				return false;
 			}
 
 			if ( ctx.endsWith( 'heading2 $text' ) && attributeName == 'italic' ) {
-				evt.stop();
-				evt.return = false;
+				return false;
 			}
 
 			if ( ctx.endsWith( 'blockQuote listItem $text' ) && attributeName == 'linkHref' ) {
-				evt.stop();
-				evt.return = false;
+				return false;
 			}
 
 			if ( ctx.endsWith( 'paragraph $text' ) && attributeName == 'bold' ) {
-				evt.stop();
-				evt.return = false;
+				return false;
 			}
 		} );
 
-		schema.on( 'checkChild', ( evt, args ) => {
-			const def = schema.getDefinition( args[ 1 ] );
-
-			if ( args[ 0 ].endsWith( '$root' ) && def.name == 'heading3' ) {
-				evt.stop();
-				evt.return = false;
+		schema.addChildCheck( ( ctx, childDef ) => {
+			if ( ctx.endsWith( '$root' ) && childDef.name == 'heading3' ) {
+				return false;
 			}
 		} );
 	} )

+ 202 - 75
packages/ckeditor5-engine/tests/model/schema.js

@@ -161,7 +161,7 @@ describe( 'Schema', () => {
 			} );
 		} );
 
-		it( 'ensures no unregistered items in allowIn', () => {
+		it( 'ensures no non-registered items in allowIn', () => {
 			schema.register( 'foo', {
 				allowIn: '$root'
 			} );
@@ -256,7 +256,7 @@ describe( 'Schema', () => {
 			expect( schema.getDefinition( ctx.last ).isMe ).to.be.true;
 		} );
 
-		it( 'returns undefined when trying to get an unregistered item', () => {
+		it( 'returns undefined when trying to get an non-registered item', () => {
 			expect( schema.getDefinition( '404' ) ).to.be.undefined;
 		} );
 	} );
@@ -426,7 +426,16 @@ describe( 'Schema', () => {
 			expect( schema.checkChild( root1, new Text( 'foo' ) ) ).to.be.false;
 		} );
 
-		// TODO checks fires event
+		it( 'fires the checkChild event with already normalized params', done => {
+			schema.on( 'checkChild', ( evt, [ ctx, child ] ) => {
+				expect( ctx ).to.be.instanceof( SchemaContext );
+				expect( child ).to.equal( schema.getDefinition( 'paragraph' ) );
+
+				done();
+			}, { priority: 'highest' } );
+
+			schema.checkChild( root1, r1p1 );
+		} );
 	} );
 
 	describe( 'checkAttribute()', () => {
@@ -477,7 +486,159 @@ describe( 'Schema', () => {
 			expect( schema.checkAttribute( contextInText, 'bold' ) ).to.be.true;
 		} );
 
-		// TODO checks fires event
+		it( 'fires the checkAttribute event with already normalized context', done => {
+			schema.on( 'checkAttribute', ( evt, [ ctx, attributeName ] ) => {
+				expect( ctx ).to.be.instanceof( SchemaContext );
+				expect( attributeName ).to.equal( 'bold' );
+
+				done();
+			}, { priority: 'highest' } );
+
+			schema.checkAttribute( r1p1, 'bold' );
+		} );
+	} );
+
+	describe( 'addChildCheck()', () => {
+		beforeEach( () => {
+			schema.register( '$root' );
+			schema.register( 'paragraph', {
+				allowIn: '$root'
+			} );
+		} );
+
+		it( 'adds a high-priority listener', () => {
+			const order = [];
+
+			schema.on( 'checkChild', () => {
+				order.push( 'checkChild:high-before' );
+			}, { priority: 'high' } );
+
+			schema.addChildCheck( () => {
+				order.push( 'addChildCheck' );
+			} );
+
+			schema.on( 'checkChild', () => {
+				order.push( 'checkChild:high-after' );
+			}, { priority: 'high' } );
+
+			schema.checkChild( root1, r1p1 );
+
+			expect( order.join() ).to.equal( 'checkChild:high-before,addChildCheck,checkChild:high-after' );
+		} );
+
+		it( 'stops the event and overrides the return value when callback returned true', () => {
+			schema.register( '$text' );
+
+			expect( schema.checkChild( root1, '$text' ) ).to.be.false;
+
+			schema.addChildCheck( () => {
+				return true;
+			} );
+
+			schema.on( 'checkChild', () => {
+				throw new Error( 'the event should be stopped' );
+			}, { priority: 'high' } );
+
+			expect( schema.checkChild( root1, '$text' ) ).to.be.true;
+		} );
+
+		it( 'stops the event and overrides the return value when callback returned false', () => {
+			expect( schema.checkChild( root1, r1p1 ) ).to.be.true;
+
+			schema.addChildCheck( () => {
+				return false;
+			} );
+
+			schema.on( 'checkChild', () => {
+				throw new Error( 'the event should be stopped' );
+			}, { priority: 'high' } );
+
+			expect( schema.checkChild( root1, r1p1 ) ).to.be.false;
+		} );
+
+		it( 'receives context and child definition as params', () => {
+			schema.addChildCheck( ( ctx, childDef ) => {
+				expect( ctx ).to.be.instanceOf( SchemaContext );
+				expect( childDef ).to.equal( schema.getDefinition( 'paragraph' ) );
+			} );
+
+			expect( schema.checkChild( root1, r1p1 ) ).to.be.true;
+		} );
+
+		it( 'is not called when checking a non-registered element', () => {
+			expect( schema.getDefinition( 'foo' ) ).to.be.undefined;
+
+			schema.addChildCheck( () => {
+				throw new Error( 'callback should not be called' );
+			} );
+
+			expect( schema.checkChild( root1, 'foo' ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'addAttributeCheck()', () => {
+		beforeEach( () => {
+			schema.register( 'paragraph', {
+				allowAttributes: 'foo'
+			} );
+		} );
+
+		it( 'adds a high-priority listener', () => {
+			const order = [];
+
+			schema.on( 'checkAttribute', () => {
+				order.push( 'checkAttribute:high-before' );
+			}, { priority: 'high' } );
+
+			schema.addAttributeCheck( () => {
+				order.push( 'addAttributeCheck' );
+			} );
+
+			schema.on( 'checkAttribute', () => {
+				order.push( 'checkAttribute:high-after' );
+			}, { priority: 'high' } );
+
+			schema.checkAttribute( r1p1, 'foo' );
+
+			expect( order.join() ).to.equal( 'checkAttribute:high-before,addAttributeCheck,checkAttribute:high-after' );
+		} );
+
+		it( 'stops the event and overrides the return value when callback returned true', () => {
+			expect( schema.checkAttribute( r1p1, 'bar' ) ).to.be.false;
+
+			schema.addAttributeCheck( () => {
+				return true;
+			} );
+
+			schema.on( 'checkAttribute', () => {
+				throw new Error( 'the event should be stopped' );
+			}, { priority: 'high' } );
+
+			expect( schema.checkAttribute( r1p1, 'bar' ) ).to.be.true;
+		} );
+
+		it( 'stops the event and overrides the return value when callback returned false', () => {
+			expect( schema.checkAttribute( r1p1, 'foo' ) ).to.be.true;
+
+			schema.addAttributeCheck( () => {
+				return false;
+			} );
+
+			schema.on( 'checkAttribute', () => {
+				throw new Error( 'the event should be stopped' );
+			}, { priority: 'high' } );
+
+			expect( schema.checkAttribute( r1p1, 'foo' ) ).to.be.false;
+		} );
+
+		it( 'receives context and attribute name as params', () => {
+			schema.addAttributeCheck( ( ctx, attributeName ) => {
+				expect( ctx ).to.be.instanceOf( SchemaContext );
+				expect( attributeName ).to.equal( 'foo' );
+			} );
+
+			expect( schema.checkAttribute( r1p1, 'foo' ) ).to.be.true;
+		} );
 	} );
 
 	describe( 'getLimitElement()', () => {
@@ -611,22 +772,17 @@ describe( 'Schema', () => {
 				allowAttributes: [ 'name', 'title' ]
 			} );
 
-			schema.on( 'checkAttribute', ( evt, args ) => {
-				const ctx = args[ 0 ];
-				const attributeName = args[ 1 ];
-
+			schema.addAttributeCheck( ( ctx, attributeName ) => {
 				// Allow 'bold' on p>$text.
 				if ( ctx.endsWith( 'p $text' ) && attributeName == 'bold' ) {
-					evt.stop();
-					evt.return = true;
+					return true;
 				}
 
 				// Allow 'bold' on $root>p.
 				if ( ctx.endsWith( '$root p' ) && attributeName == 'bold' ) {
-					evt.stop();
-					evt.return = true;
+					return true;
 				}
-			}, { priority: 'high' } );
+			} );
 		} );
 
 		describe( 'when selection is collapsed', () => {
@@ -701,22 +857,17 @@ describe( 'Schema', () => {
 				allowWhere: '$text'
 			} );
 
-			schema.on( 'checkAttribute', ( evt, args ) => {
-				const ctx = args[ 0 ];
-				const attributeName = args[ 1 ];
-
+			schema.addAttributeCheck( ( ctx, attributeName ) => {
 				// Allow 'bold' on p>$text.
 				if ( ctx.endsWith( 'p $text' ) && attributeName == 'bold' ) {
-					evt.stop();
-					evt.return = true;
+					return true;
 				}
 
 				// Allow 'bold' on $root>p.
 				if ( ctx.endsWith( '$root p' ) && attributeName == 'bold' ) {
-					evt.stop();
-					evt.return = true;
+					return true;
 				}
-			}, { priority: 'high' } );
+			} );
 
 			setData( model, '<p>foo<img />bar</p>' );
 
@@ -752,16 +903,12 @@ describe( 'Schema', () => {
 		it( 'should return three ranges when attribute is not allowed on one element but is allowed on its child', () => {
 			schema.extend( '$text', { allowIn: 'img' } );
 
-			schema.on( 'checkAttribute', ( evt, args ) => {
-				const ctx = args[ 0 ];
-				const attributeName = args[ 1 ];
-
+			schema.addAttributeCheck( ( ctx, attributeName ) => {
 				// Allow 'bold' on img>$text.
 				if ( ctx.endsWith( 'img $text' ) && attributeName == 'bold' ) {
-					evt.stop();
-					evt.return = true;
+					return true;
 				}
-			}, { priority: 'high' } );
+			} );
 
 			setData( model, '[<p>foo<img>xxx</img>bar</p>]' );
 
@@ -795,16 +942,12 @@ describe( 'Schema', () => {
 		} );
 
 		it( 'should split range into two ranges and omit disallowed element', () => {
-			schema.on( 'checkAttribute', ( evt, args ) => {
-				const ctx = args[ 0 ];
-				const attributeName = args[ 1 ];
-
+			schema.addAttributeCheck( ( ctx, attributeName ) => {
 				// Disallow 'bold' on p>img.
 				if ( ctx.endsWith( 'p img' ) && attributeName == 'bold' ) {
-					evt.stop();
-					evt.return = false;
+					return false;
 				}
-			}, { priority: 'high' } );
+			} );
 
 			const result = schema.getValidRanges( ranges, attribute );
 
@@ -864,34 +1007,27 @@ describe( 'Schema', () => {
 		} );
 
 		it( 'should filter out disallowed attributes from all descendants of given nodes', () => {
-			schema.on( 'checkAttribute', ( evt, args ) => {
-				const ctx = args[ 0 ];
-				const attributeName = args[ 1 ];
-
+			schema.addAttributeCheck( ( ctx, attributeName ) => {
 				// Allow 'a' on div>$text.
 				if ( ctx.endsWith( 'div $text' ) && attributeName == 'a' ) {
-					evt.stop();
-					evt.return = true;
+					return true;
 				}
 
 				// Allow 'b' on div>paragraph>$text.
 				if ( ctx.endsWith( 'div paragraph $text' ) && attributeName == 'b' ) {
-					evt.stop();
-					evt.return = true;
+					return true;
 				}
 
 				// Allow 'a' on div>image.
 				if ( ctx.endsWith( 'div image' ) && attributeName == 'a' ) {
-					evt.stop();
-					evt.return = true;
+					return true;
 				}
 
 				// Allow 'b' on div>paragraph>image.
 				if ( ctx.endsWith( 'div paragraph image' ) && attributeName == 'b' ) {
-					evt.stop();
-					evt.return = true;
+					return true;
 				}
-			}, { priority: 'high' } );
+			} );
 
 			const foo = new Text( 'foo', { a: 1, b: 1 } );
 			const bar = new Text( 'bar', { a: 1, b: 1 } );
@@ -983,7 +1119,7 @@ describe( 'Schema', () => {
 				expect( schema.checkChild( div, div ) ).to.be.true;
 			} );
 
-			it( 'rejects $root>paragraph – unregistered paragraph', () => {
+			it( 'rejects $root>paragraph – non-registered paragraph', () => {
 				schema.register( '$root' );
 
 				expect( schema.checkChild( root1, r1p1 ) ).to.be.false;
@@ -1412,7 +1548,7 @@ describe( 'Schema', () => {
 				expect( schema.checkChild( root1, 'foo404' ) ).to.be.false;
 			} );
 
-			it( 'does not break when trying to check registered child in a context which contains unregistered elements', () => {
+			it( 'does not break when trying to check registered child in a context which contains non-registered elements', () => {
 				const foo404 = new Element( 'foo404' );
 
 				root1.appendChildren( foo404 );
@@ -1425,7 +1561,7 @@ describe( 'Schema', () => {
 				expect( schema.checkChild( foo404, '$text' ) ).to.be.false;
 			} );
 
-			it( 'does not break when used allowedIn pointing to an unregistered element', () => {
+			it( 'does not break when used allowedIn pointing to an non-registered element', () => {
 				schema.register( '$root' );
 				schema.register( '$text', {
 					allowIn: 'foo404'
@@ -1434,7 +1570,7 @@ describe( 'Schema', () => {
 				expect( schema.checkChild( root1, '$text' ) ).to.be.false;
 			} );
 
-			it( 'does not break when used allowWhere pointing to an unregistered element', () => {
+			it( 'does not break when used allowWhere pointing to an non-registered element', () => {
 				schema.register( '$root' );
 				schema.register( '$text', {
 					allowWhere: 'foo404'
@@ -1443,7 +1579,7 @@ describe( 'Schema', () => {
 				expect( schema.checkChild( root1, '$text' ) ).to.be.false;
 			} );
 
-			it( 'does not break when used allowContentOf pointing to an unregistered element', () => {
+			it( 'does not break when used allowContentOf pointing to an non-registered element', () => {
 				schema.register( '$root', {
 					allowContentOf: 'foo404'
 				} );
@@ -1463,7 +1599,7 @@ describe( 'Schema', () => {
 				expect( schema.checkChild( root1, 'paragraph' ) ).to.be.false;
 			} );
 
-			it( 'does not break when inheriting all from an unregistered element', () => {
+			it( 'does not break when inheriting all from an non-registered element', () => {
 				schema.register( 'paragraph', {
 					inheritAllFrom: '$block'
 				} );
@@ -1573,11 +1709,11 @@ describe( 'Schema', () => {
 		} );
 
 		describe( 'missing attribute definitions', () => {
-			it( 'does not crash when checking an attribute of a unregistered element', () => {
+			it( 'does not crash when checking an attribute of a non-registered element', () => {
 				expect( schema.checkAttribute( r1p1, 'align' ) ).to.be.false;
 			} );
 
-			it( 'does not crash when inheriting attributes of a unregistered element', () => {
+			it( 'does not crash when inheriting attributes of a non-registered element', () => {
 				schema.register( 'paragraph', {
 					allowAttributesOf: '$block'
 				} );
@@ -1585,7 +1721,7 @@ describe( 'Schema', () => {
 				expect( schema.checkAttribute( r1p1, 'whatever' ) ).to.be.false;
 			} );
 
-			it( 'does not crash when inheriting all from a unregistered element', () => {
+			it( 'does not crash when inheriting all from a non-registered element', () => {
 				schema.register( 'paragraph', {
 					allowAttributesOf: '$block'
 				} );
@@ -1595,7 +1731,7 @@ describe( 'Schema', () => {
 		} );
 
 		describe( 'missing types definitions', () => {
-			it( 'does not crash when inheriting types of an unregistered element', () => {
+			it( 'does not crash when inheriting types of an non-registered element', () => {
 				schema.register( 'paragraph', {
 					inheritTypesFrom: '$block'
 				} );
@@ -1632,16 +1768,11 @@ describe( 'Schema', () => {
 				} );
 
 				// Disallow blockQuote in blockQuote.
-				schema.on( 'checkChild', ( evt, args ) => {
-					const ctx = args[ 0 ];
-					const child = args[ 1 ];
-					const childRule = schema.getDefinition( child );
-
-					if ( childRule.name == 'blockQuote' && ctx.endsWith( 'blockQuote' ) ) {
-						evt.stop();
-						evt.return = false;
+				schema.addChildCheck( ( ctx, childDef ) => {
+					if ( childDef.name == 'blockQuote' && ctx.endsWith( 'blockQuote' ) ) {
+						return false;
 					}
-				}, { priority: 'high' } );
+				} );
 			},
 			() => {
 				schema.register( 'image', {
@@ -1664,15 +1795,11 @@ describe( 'Schema', () => {
 				} );
 
 				// Disallow bold in heading1.
-				schema.on( 'checkAttribute', ( evt, args ) => {
-					const ctx = args[ 0 ];
-					const attributeName = args[ 1 ];
-
+				schema.addAttributeCheck( ( ctx, attributeName ) => {
 					if ( ctx.endsWith( 'heading1 $text' ) && attributeName == 'bold' ) {
-						evt.stop();
-						evt.return = false;
+						return false;
 					}
-				}, { priority: 'high' } );
+				} );
 			},
 			() => {
 				schema.extend( '$block', {

+ 8 - 14
packages/ckeditor5-engine/tests/model/utils/deletecontent.js

@@ -466,28 +466,22 @@ describe( 'DataController utils', () => {
 				beforeEach( () => {
 					const schema = model.schema;
 
-					schema.on( 'checkAttribute', ( evt, args ) => {
-						const ctx = args[ 0 ];
-						const attributeName = args[ 1 ];
+					schema.addAttributeCheck( ( ctx, attributeName ) => {
+						// Disallow 'c' on pchild>pchild>$text.
+						if ( ctx.endsWith( 'pchild pchild $text' ) && attributeName == 'c' ) {
+							return false;
+						}
 
 						// Allow 'a' and 'b' on paragraph>$text.
 						if ( ctx.endsWith( 'paragraph $text' ) && [ 'a', 'b' ].includes( attributeName ) ) {
-							evt.stop();
-							evt.return = true;
+							return true;
 						}
 
 						// Allow 'b' and 'c' in pchild>$text.
 						if ( ctx.endsWith( 'pchild $text' ) && [ 'b', 'c' ].includes( attributeName ) ) {
-							evt.stop();
-							evt.return = true;
-						}
-
-						// Disallow 'c' on pchild>pchild>$text.
-						if ( ctx.endsWith( 'pchild pchild $text' ) && attributeName == 'c' ) {
-							evt.stop();
-							evt.return = false;
+							return true;
 						}
-					}, { priority: 'high' } );
+					} );
 
 					schema.extend( 'pchild', { allowIn: 'pchild' } );
 				} );

+ 9 - 21
packages/ckeditor5-engine/tests/model/utils/insertcontent.js

@@ -273,14 +273,9 @@ describe( 'DataController utils', () => {
 
 			it( 'not insert autoparagraph when paragraph is disallowed at the current position', () => {
 				// Disallow paragraph in $root.
-				model.schema.on( 'checkChild', ( evt, args ) => {
-					const ctx = args[ 0 ];
-					const child = args[ 1 ];
-					const childRule = model.schema.getDefinition( child );
-
-					if ( childRule.name == 'paragraph' && ctx.endsWith( '$root' ) ) {
-						evt.stop();
-						evt.return = false;
+				model.schema.addChildCheck( ( ctx, childDef ) => {
+					if ( childDef.name == 'paragraph' && ctx.endsWith( '$root' ) ) {
+						return false;
 					}
 				} );
 
@@ -618,34 +613,27 @@ describe( 'DataController utils', () => {
 				schema.extend( 'element', { allowIn: 'paragraph' } );
 				schema.extend( 'element', { allowIn: 'heading1' } );
 
-				schema.on( 'checkAttribute', ( evt, args ) => {
-					const ctx = args[ 0 ];
-					const attributeName = args[ 1 ];
-
+				schema.addAttributeCheck( ( ctx, attributeName ) => {
 					// Allow 'b' on paragraph>$text.
 					if ( ctx.endsWith( 'paragraph $text' ) && attributeName == 'b' ) {
-						evt.stop();
-						evt.return = true;
+						return true;
 					}
 
 					// Allow 'b' on paragraph>element>$text.
 					if ( ctx.endsWith( 'paragraph element $text' ) && attributeName == 'b' ) {
-						evt.stop();
-						evt.return = true;
+						return true;
 					}
 
 					// Allow 'a' and 'b' on heading1>element>$text.
 					if ( ctx.endsWith( 'heading1 element $text' ) && [ 'a', 'b' ].includes( attributeName ) ) {
-						evt.stop();
-						evt.return = true;
+						return true;
 					}
 
 					// Allow 'b' on element>table>td>$text.
 					if ( ctx.endsWith( 'element table td $text' ) && attributeName == 'b' ) {
-						evt.stop();
-						evt.return = true;
+						return true;
 					}
-				}, { priority: 'high' } );
+				} );
 			} );
 
 			it( 'filters out disallowed elements and leaves out the text', () => {

+ 12 - 11
packages/ckeditor5-engine/tests/view/document/jumpoveruielement.js

@@ -20,13 +20,17 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 describe( 'Document', () => {
 	let viewDocument, domRoot, domSelection, viewRoot, foo, bar, ui, ui2;
 
-	class MyUIElement extends UIElement {
-		render( domDocument ) {
-			const element = super.render( domDocument );
-			element.innerText = this.contents;
+	function createUIElement( name, contents ) {
+		const element = new UIElement( name );
 
-			return element;
-		}
+		element.render = function( domDocument ) {
+			const domElement = this.toDomElement( domDocument );
+			domElement.innerText = contents;
+
+			return domElement;
+		};
+
+		return element;
 	}
 
 	beforeEach( () => {
@@ -46,11 +50,8 @@ describe( 'Document', () => {
 
 		foo = new ViewText( 'foo' );
 		bar = new ViewText( 'bar' );
-		ui = new MyUIElement( 'span' );
-		ui.contents = 'xxx';
-
-		ui2 = new MyUIElement( 'span' );
-		ui2.contents = 'yyy';
+		ui = createUIElement( 'span', 'xxx' );
+		ui2 = createUIElement( 'span', 'yyy' );
 	} );
 
 	afterEach( () => {

+ 18 - 14
packages/ckeditor5-engine/tests/view/domconverter/uielement.js

@@ -12,13 +12,17 @@ import DomConverter from '../../../src/view/domconverter';
 describe( 'DOMConverter UIElement integration', () => {
 	let converter;
 
-	class MyUIElement extends ViewUIElement {
-		render( domDocument ) {
-			const root = super.render( domDocument );
+	function createUIElement( name ) {
+		const element = new ViewUIElement( name );
+
+		element.render = function( domDocument ) {
+			const root = this.toDomElement( domDocument );
 			root.innerHTML = '<p><span>foo</span> bar</p>';
 
 			return root;
-		}
+		};
+
+		return element;
 	}
 
 	beforeEach( () => {
@@ -34,7 +38,7 @@ describe( 'DOMConverter UIElement integration', () => {
 		} );
 
 		it( 'should create DOM structure from UIElement', () => {
-			const myElement = new MyUIElement( 'div' );
+			const myElement = createUIElement( 'div' );
 			const domElement = converter.viewToDom( myElement, document );
 
 			expect( domElement ).to.be.instanceOf( HTMLElement );
@@ -42,7 +46,7 @@ describe( 'DOMConverter UIElement integration', () => {
 		} );
 
 		it( 'should create DOM structure that all is mapped to single UIElement', () => {
-			const myElement = new MyUIElement( 'div' );
+			const myElement = createUIElement( 'div' );
 			const domElement = converter.viewToDom( myElement, document, { bind: true } );
 			const domParagraph = domElement.childNodes[ 0 ];
 
@@ -54,14 +58,14 @@ describe( 'DOMConverter UIElement integration', () => {
 
 	describe( 'domToView()', () => {
 		it( 'should return UIElement itself', () => {
-			const uiElement = new MyUIElement( 'div' );
+			const uiElement = createUIElement( 'div' );
 			const domElement = converter.viewToDom( uiElement, document, { bind: true } );
 
 			expect( converter.domToView( domElement ) ).to.equal( uiElement );
 		} );
 
 		it( 'should return UIElement for nodes inside', () => {
-			const uiElement = new MyUIElement( 'div' );
+			const uiElement = createUIElement( 'div' );
 			const domElement = converter.viewToDom( uiElement, document, { bind: true } );
 
 			const domParagraph = domElement.childNodes[ 0 ];
@@ -76,7 +80,7 @@ describe( 'DOMConverter UIElement integration', () => {
 
 	describe( 'domPositionToView()', () => {
 		it( 'should convert position inside UIElement to position before it', () => {
-			const uiElement = new MyUIElement( 'h1' );
+			const uiElement = createUIElement( 'h1' );
 			const container = new ViewContainer( 'div', null, [ new ViewContainer( 'div' ), uiElement ] );
 			const domContainer = converter.viewToDom( container, document, { bind: true } );
 
@@ -87,7 +91,7 @@ describe( 'DOMConverter UIElement integration', () => {
 		} );
 
 		it( 'should convert position inside UIElement children to position before UIElement', () => {
-			const uiElement = new MyUIElement( 'h1' );
+			const uiElement = createUIElement( 'h1' );
 			const container = new ViewContainer( 'div', null, [ new ViewContainer( 'div' ), uiElement ] );
 			const domContainer = converter.viewToDom( container, document, { bind: true } );
 
@@ -100,7 +104,7 @@ describe( 'DOMConverter UIElement integration', () => {
 
 	describe( 'mapDomToView()', () => {
 		it( 'should return UIElement for DOM elements inside', () => {
-			const myElement = new MyUIElement( 'div' );
+			const myElement = createUIElement( 'div' );
 			const domElement = converter.viewToDom( myElement, document, { bind: true } );
 
 			expect( converter.mapDomToView( domElement ) ).to.equal( myElement );
@@ -115,7 +119,7 @@ describe( 'DOMConverter UIElement integration', () => {
 
 	describe( 'findCorrespondingViewText()', () => {
 		it( 'should return UIElement for DOM text inside', () => {
-			const myElement = new MyUIElement( 'div' );
+			const myElement = createUIElement( 'div' );
 			const domElement = converter.viewToDom( myElement, document, { bind: true } );
 
 			const domText = domElement.querySelector( 'span' ).childNodes[ 0 ];
@@ -125,7 +129,7 @@ describe( 'DOMConverter UIElement integration', () => {
 
 	describe( 'getParentUIElement()', () => {
 		it( 'should return UIElement for DOM children', () => {
-			const uiElement = new MyUIElement( 'div' );
+			const uiElement = createUIElement( 'div' );
 			const domElement = converter.viewToDom( uiElement, document, { bind: true } );
 
 			const domParagraph = domElement.childNodes[ 0 ];
@@ -136,7 +140,7 @@ describe( 'DOMConverter UIElement integration', () => {
 		} );
 
 		it( 'should return null for element itself', () => {
-			const uiElement = new MyUIElement( 'div' );
+			const uiElement = createUIElement( 'div' );
 			const domElement = converter.viewToDom( uiElement, document, { bind: true } );
 
 			expect( converter.getParentUIElement( domElement ) ).to.be.null;

+ 19 - 13
packages/ckeditor5-engine/tests/view/manual/uielement.js

@@ -17,26 +17,32 @@ import UIElement from '../../../src/view/uielement';
 import Position from '../../../src/view/position';
 import writer from '../../../src/view/writer';
 
-class EndingUIElement extends UIElement {
-	render( domDocument ) {
-		const root = super.render( domDocument );
+function createEndingUIElement() {
+	const element = new UIElement( 'span' );
 
+	element.render = function( domDocument ) {
+		const root = this.toDomElement( domDocument );
 		root.classList.add( 'ui-element' );
 		root.innerHTML = 'END OF PARAGRAPH';
 
 		return root;
-	}
+	};
+
+	return element;
 }
 
-class MiddleUIElement extends UIElement {
-	render( domDocument ) {
-		const root = super.render( domDocument );
+function createMiddleUIElement() {
+	const element = new UIElement( 'span' );
 
+	element.render = function( domDocument ) {
+		const root = this.toDomElement( domDocument );
 		root.classList.add( 'ui-element' );
 		root.innerHTML = 'X';
 
 		return root;
-	}
+	};
+
+	return element;
 }
 
 class UIElementTestPlugin extends Plugin {
@@ -47,7 +53,7 @@ class UIElementTestPlugin extends Plugin {
 		// Add some UIElement to each paragraph.
 		editing.modelToView.on( 'insert:paragraph', ( evt, data, consumable, conversionApi ) => {
 			const viewP = conversionApi.mapper.toViewElement( data.item );
-			viewP.appendChildren( new EndingUIElement( 'span' ) );
+			viewP.appendChildren( createEndingUIElement() );
 		}, { priority: 'lowest' } );
 	}
 }
@@ -65,10 +71,10 @@ ClassicEditor
 		const viewText1 = viewRoot.getChild( 0 ).getChild( 0 );
 		const viewText2 = viewRoot.getChild( 1 ).getChild( 0 );
 
-		writer.insert( new Position( viewText1, 20 ), new MiddleUIElement( 'span' ) );
-		writer.insert( new Position( viewText1, 20 ), new MiddleUIElement( 'span' ) );
-		writer.insert( new Position( viewText2, 0 ), new MiddleUIElement( 'span' ) );
-		writer.insert( new Position( viewText2, 6 ), new MiddleUIElement( 'span' ) );
+		writer.insert( new Position( viewText1, 20 ), createMiddleUIElement() );
+		writer.insert( new Position( viewText1, 20 ), createMiddleUIElement() );
+		writer.insert( new Position( viewText2, 0 ), createMiddleUIElement() );
+		writer.insert( new Position( viewText2, 6 ), createMiddleUIElement() );
 
 		editor.editing.view.render();
 	} )

+ 9 - 5
packages/ckeditor5-engine/tests/view/observer/domeventobserver.js

@@ -163,20 +163,24 @@ describe( 'DomEventObserver', () => {
 	describe( 'integration with UIElement', () => {
 		let domRoot, domEvent, evtSpy, uiElement;
 
-		class MyUIElement extends UIElement {
-			render( domDocument ) {
-				const root = super.render( domDocument );
+		function createUIElement( name ) {
+			const element = new UIElement( name );
+
+			element.render = function( domDocument ) {
+				const root = this.toDomElement( domDocument );
 				root.innerHTML = '<span>foo bar</span>';
 
 				return root;
-			}
+			};
+
+			return element;
 		}
 
 		beforeEach( () => {
 			domRoot = document.createElement( 'div' );
 			const viewRoot = createViewRoot( viewDocument );
 			viewDocument.attachDomRoot( domRoot );
-			uiElement = new MyUIElement( 'p' );
+			uiElement = createUIElement( 'p' );
 			viewRoot.appendChildren( uiElement );
 			viewDocument.render();
 

+ 9 - 5
packages/ckeditor5-engine/tests/view/observer/mutationobserver.js

@@ -420,17 +420,21 @@ describe( 'MutationObserver', () => {
 	} );
 
 	describe( 'UIElement integration', () => {
-		class MyUIElement extends UIElement {
-			render( domDocument ) {
-				const root = super.render( domDocument );
+		function createUIElement( name ) {
+			const element = new UIElement( name );
+
+			element.render = function( domDocument ) {
+				const root = this.toDomElement( domDocument );
 				root.innerHTML = 'foo bar';
 
 				return root;
-			}
+			};
+
+			return element;
 		}
 
 		beforeEach( () => {
-			const uiElement = new MyUIElement( 'div' );
+			const uiElement = createUIElement( 'div' );
 			viewRoot.appendChildren( uiElement );
 
 			viewDocument.render();

+ 22 - 0
packages/ckeditor5-engine/tests/view/uielement.js

@@ -122,5 +122,27 @@ describe( 'UIElement', () => {
 				expect( domElement.getAttribute( key ) ).to.equal( uiElement.getAttribute( key ) );
 			}
 		} );
+
+		it( 'should allow to change render() method', () => {
+			uiElement.render = function( domDocument ) {
+				return domDocument.createElement( 'b' );
+			};
+
+			expect( uiElement.render( document ).tagName.toLowerCase() ).to.equal( 'b' );
+		} );
+
+		it( 'should allow to add new elements inside', () => {
+			uiElement.render = function( domDocument ) {
+				const element = this.toDomElement( domDocument );
+				const text = domDocument.createTextNode( 'foo bar' );
+				element.appendChild( text );
+
+				return element;
+			};
+
+			const rendered = uiElement.render( document );
+			expect( rendered.tagName.toLowerCase() ).to.equal( 'span' );
+			expect( rendered.textContent ).to.equal( 'foo bar' );
+		} );
 	} );
 } );