Bladeren bron

Introduced SchemaContext and simplified check method callbacks.

Piotrek Koszuliński 8 jaren geleden
bovenliggende
commit
dfaa9ce407

+ 61 - 19
packages/ckeditor5-engine/src/model/schema.js

@@ -23,11 +23,11 @@ export default class Schema {
 		this.decorate( 'checkAttribute' );
 
 		this.on( 'checkAttribute', ( evt, args ) => {
-			args[ 0 ] = normalizeContext( args[ 0 ] );
+			args[ 0 ] = new SchemaContext( args[ 0 ] );
 		}, { priority: 'highest' } );
 
 		this.on( 'checkChild', ( evt, args ) => {
-			args[ 0 ] = normalizeContext( args[ 0 ] );
+			args[ 0 ] = new SchemaContext( args[ 0 ] );
 		}, { priority: 'highest' } );
 	}
 
@@ -125,7 +125,7 @@ export default class Schema {
 	 * @param {String}
 	 */
 	checkAttribute( context, attributeName ) {
-		const rule = this.getRule( context[ context.length - 1 ] );
+		const rule = this.getRule( context.last );
 
 		if ( !rule ) {
 			return false;
@@ -218,7 +218,7 @@ export default class Schema {
 	}
 
 	_checkContextMatch( rule, context, contextItemIndex = context.length - 1 ) {
-		const contextItem = context[ contextItemIndex ];
+		const contextItem = context.getItem( contextItemIndex );
 
 		if ( rule.allowIn.includes( contextItem.name ) ) {
 			if ( contextItemIndex == 0 ) {
@@ -236,6 +236,50 @@ export default class Schema {
 
 mix( Schema, ObservableMixin );
 
+/**
+ * @private
+ */
+export class SchemaContext {
+	constructor( ctx ) {
+		if ( Array.isArray( ctx ) ) {
+			this._items = ctx.map( mapContextItem );
+		}
+		// Item or position (PS. It's ok that Position#getAncestors() doesn't accept params).
+		else {
+			this._items = ctx.getAncestors( { includeSelf: true } ).map( mapContextItem );
+		}
+	}
+
+	get length() {
+		return this._items.length;
+	}
+
+	get last() {
+		return this._items[ this._items.length - 1 ];
+	}
+
+	/**
+	 * Returns an iterator that iterates over all context items
+	 *
+	 * @returns {Iterator.<TODO>}
+	 */
+	[ Symbol.iterator ]() {
+		return this._items[ Symbol.iterator ]();
+	}
+
+	getItem( index ) {
+		return this._items[ index ];
+	}
+
+	* getNames() {
+		yield* this._items.map( item => item.name );
+	}
+
+	matchEnd( query ) {
+		return Array.from( this.getNames() ).join( ' ' ).endsWith( query );
+	}
+}
+
 function compileBaseItemRule( sourceItemRules, itemName ) {
 	const itemRule = {
 		name: itemName,
@@ -384,32 +428,30 @@ function getAllowedChildren( compiledRules, itemName ) {
 	return getValues( compiledRules ).filter( rule => rule.allowIn.includes( itemRule.name ) );
 }
 
-function normalizeContext( ctx ) {
-	if ( Array.isArray( ctx ) ) {
-		return ctx.map( mapContextItem );
-	}
-	// Item or position (PS. It's ok that Position#getAncestors() doesn't accept params).
-	else {
-		return ctx.getAncestors( { includeSelf: true } ).map( mapContextItem );
-	}
+function getValues( obj ) {
+	return Object.keys( obj ).map( key => obj[ key ] );
 }
 
 function mapContextItem( ctxItem ) {
 	if ( typeof ctxItem == 'string' ) {
 		return {
 			name: ctxItem,
-			* getAttributes() {}
+
+			* getAttributeKeys() {},
+
+			getAttribute() {}
 		};
 	} else {
 		return {
 			name: ctxItem.is( 'text' ) ? '$text' : ctxItem.name,
-			* getAttributes() {
-				yield* ctxItem.getAttributes();
+
+			* getAttributeKeys() {
+				yield* ctxItem.getAttributeKeys();
+			},
+
+			getAttribute( key ) {
+				return ctxItem.getAttribute( key );
 			}
 		};
 	}
 }
-
-function getValues( obj ) {
-	return Object.keys( obj ).map( key => obj[ key ] );
-}

+ 3 - 5
packages/ckeditor5-engine/tests/conversion/buildviewconverter.js

@@ -496,11 +496,11 @@ describe( 'View converter builder', () => {
 
 		// Disallow $root>div.
 		schema.on( 'checkChild', ( evt, args ) => {
-			const context = args[ 0 ];
+			const ctx = args[ 0 ];
 			const child = args[ 1 ];
 			const childRule = schema.getRule( child );
 
-			if ( childRule.name == 'div' && context[ context.length - 1 ].name == '$root' ) {
+			if ( childRule.name == 'div' && ctx.matchEnd( '$root' ) ) {
 				evt.stop();
 				evt.return = false;
 			}
@@ -529,11 +529,9 @@ describe( 'View converter builder', () => {
 	// 	// Disallow bold in paragraph>$text.
 	// 	schema.on( 'checkAttribute', ( evt, args ) => {
 	// 		const context = args[ 0 ];
-	// 		const ctxItem = context[ context.length - 1 ];
-	// 		const ctxParent = context[ context.length - 2 ];
 	// 		const attributeName = args[ 1 ];
 
-	// 		if ( ctxItem.name == '$text' && ctxParent.name == 'paragraph' && attributeName == 'bold' ) {
+	// 		if ( ctx.matchEnd( 'paragraph $text' ) && attributeName == 'bold' ) {
 	// 			evt.stop();
 	// 			evt.return = false;
 	// 		}

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

@@ -64,11 +64,11 @@ describe( 'view-to-model-converters', () => {
 
 		it( 'should not convert text if it is wrong with schema', () => {
 			schema.on( 'checkChild', ( evt, args ) => {
-				const context = args[ 0 ];
+				const ctx = args[ 0 ];
 				const child = args[ 1 ];
 				const childRule = schema.getRule( child );
 
-				if ( childRule.name == '$text' && context[ context.length - 1 ].name == '$root' ) {
+				if ( childRule.name == '$text' && ctx.matchEnd( '$root' ) ) {
 					evt.stop();
 					evt.return = false;
 				}

+ 226 - 30
packages/ckeditor5-engine/tests/model/schema.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md.
  */
 
-import Schema from '../../src/model/schema';
+import Schema, { SchemaContext } from '../../src/model/schema';
 
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
@@ -533,31 +533,28 @@ describe( 'Schema', () => {
 		it( 'should filter out disallowed attributes from all descendants of given nodes', () => {
 			schema.on( 'checkAttribute', ( evt, args ) => {
 				const ctx = args[ 0 ];
-				const ctxItem = ctx[ ctx.length - 1 ];
-				const ctxParent = ctx[ ctx.length - 2 ];
-				const ctxParent2 = ctx[ ctx.length - 3 ];
 				const attributeName = args[ 1 ];
 
-				// 'a' in div>$text
-				if ( ctxItem.name == '$text' && ctxParent.name == 'div' && attributeName == 'a' ) {
+				// Allow 'a' on div>$text.
+				if ( ctx.matchEnd( 'div $text' ) && attributeName == 'a' ) {
 					evt.stop();
 					evt.return = true;
 				}
 
-				// 'b' in div>paragraph>$text
-				if ( ctxItem.name == '$text' && ctxParent.name == 'paragraph' && ctxParent2.name == 'div' && attributeName == 'b' ) {
+				// Allow 'b' on div>paragraph>$text.
+				if ( ctx.matchEnd( 'div paragraph $text' ) && attributeName == 'b' ) {
 					evt.stop();
 					evt.return = true;
 				}
 
-				// 'a' in div>image
-				if ( ctxItem.name == 'image' && ctxParent.name == 'div' && attributeName == 'a' ) {
+				// Allow 'a' on div>image.
+				if ( ctx.matchEnd( 'div image' ) && attributeName == 'a' ) {
 					evt.stop();
 					evt.return = true;
 				}
 
-				// 'b' in div>paragraph>image
-				if ( ctxItem.name == 'image' && ctxParent.name == 'paragraph' && ctxParent2.name == 'div' && attributeName == 'b' ) {
+				// Allow 'b' on div>paragraph>image.
+				if ( ctx.matchEnd( 'div paragraph image' ) && attributeName == 'b' ) {
 					evt.stop();
 					evt.return = true;
 				}
@@ -1304,11 +1301,11 @@ describe( 'Schema', () => {
 
 				// Disallow blockQuote in blockQuote.
 				schema.on( 'checkChild', ( evt, args ) => {
-					const context = args[ 0 ];
+					const ctx = args[ 0 ];
 					const child = args[ 1 ];
 					const childRule = schema.getRule( child );
 
-					if ( childRule.name == 'blockQuote' && context[ context.length - 1 ].name == 'blockQuote' ) {
+					if ( childRule.name == 'blockQuote' && ctx.matchEnd( 'blockQuote' ) ) {
 						evt.stop();
 						evt.return = false;
 					}
@@ -1336,12 +1333,10 @@ describe( 'Schema', () => {
 
 				// Disallow bold in heading1.
 				schema.on( 'checkAttribute', ( evt, args ) => {
-					const context = args[ 0 ];
-					const ctxItem = context[ context.length - 1 ];
-					const ctxParent = context[ context.length - 2 ];
+					const ctx = args[ 0 ];
 					const attributeName = args[ 1 ];
 
-					if ( ctxItem.name == '$text' && ctxParent.name == 'heading1' && attributeName == 'bold' ) {
+					if ( ctx.matchEnd( 'heading1 $text' ) && attributeName == 'bold' ) {
 						evt.stop();
 						evt.return = false;
 					}
@@ -1646,18 +1641,219 @@ describe( 'Schema', () => {
 
 	// TODO:
 	// * getValidRanges
-	// * checkAttributeInSelection
-	// * getLimitElement
-	// * removeDisallowedAttributes
-	// * test checkChild()'s both params normalization
-	//   * and see insertContent's _checkIsObject()
+	// * checkAttributeInSelectionn
 	// * add normalization to isObject(), isLimit(), isBlock(), isRegistered() and improve the existing code
-	// * inheritAllFrom should also inherit is* props (see tests documentselection getNearestSelectionRange())
+	//   * see insertContent's _checkIsObject()
 	// * test the default abstract entities (in model.js)
-	// * see clipboardHolder definition (and rename it to the pastebin)
-	// * review insertContent's _tryAutoparagraphing()
-	// * it doesn't make sense for VCD to get schema as a param (it can get it from the model)
-	// * V->M conversion tests might got outdated and would need to be reviewed if someone has a spare week ;)
-	// * Do we need both $inline and $text? It seems that it makes more sense to have "isInline" in the future.
-	// * Consider reversing context array for writing simpler callbacks
+} );
+
+describe( 'SchemaContext', () => {
+	let root;
+
+	beforeEach( () => {
+		root = new Element( '$root', null, [
+			new Element( 'blockQuote', { foo: 1 }, [
+				new Element( 'paragraph', { align: 'left' }, [
+					new Text( 'foo', { bold: true, italic: true } )
+				] )
+			] )
+		] );
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'creates context based on an array of strings', () => {
+			const ctx = new SchemaContext( [ 'a', 'b', 'c' ] );
+
+			expect( ctx.length ).to.equal( 3 );
+
+			expect( Array.from( ctx.getNames() ) ).to.deep.equal( [ 'a', 'b', 'c' ] );
+			expect( ctx.getItem( 0 ).name ).to.equal( 'a' );
+
+			expect( Array.from( ctx.getItem( 0 ).getAttributeKeys() ) ).to.be.empty;
+			expect( ctx.getItem( 0 ).getAttribute( 'foo' ) ).to.be.undefined;
+		} );
+
+		it( 'creates context based on an array of elements', () => {
+			const blockQuote = root.getChild( 0 );
+			const text = blockQuote.getChild( 0 ).getChild( 0 );
+
+			const ctx = new SchemaContext( [ blockQuote, text ] );
+
+			expect( ctx.length ).to.equal( 2 );
+
+			expect( Array.from( ctx.getNames() ) ).to.deep.equal( [ 'blockQuote', '$text' ] );
+			expect( ctx.getItem( 0 ).name ).to.equal( 'blockQuote' );
+
+			expect( Array.from( ctx.getItem( 1 ).getAttributeKeys() ).sort() ).to.deep.equal( [ 'bold', 'italic' ] );
+			expect( ctx.getItem( 1 ).getAttribute( 'bold' ) ).to.be.true;
+		} );
+
+		it( 'creates context based on a mixed array of strings and elements', () => {
+			const blockQuote = root.getChild( 0 );
+			const text = blockQuote.getChild( 0 ).getChild( 0 );
+
+			const ctx = new SchemaContext( [ blockQuote, 'paragraph', text ] );
+
+			expect( ctx.length ).to.equal( 3 );
+
+			expect( Array.from( ctx.getNames() ) ).to.deep.equal( [ 'blockQuote', 'paragraph', '$text' ] );
+		} );
+
+		it( 'creates context based on a root element', () => {
+			const ctx = new SchemaContext( root );
+
+			expect( ctx.length ).to.equal( 1 );
+
+			expect( Array.from( ctx.getNames() ) ).to.deep.equal( [ '$root' ] );
+
+			expect( Array.from( ctx.getItem( 0 ).getAttributeKeys() ) ).to.be.empty;
+			expect( ctx.getItem( 0 ).getAttribute( 'foo' ) ).to.be.undefined;
+		} );
+
+		it( 'creates context based on a nested element', () => {
+			const ctx = new SchemaContext( root.getChild( 0 ).getChild( 0 ) );
+
+			expect( ctx.length ).to.equal( 3 );
+
+			expect( Array.from( ctx.getNames() ) ).to.deep.equal( [ '$root', 'blockQuote', 'paragraph' ] );
+
+			expect( Array.from( ctx.getItem( 1 ).getAttributeKeys() ) ).to.deep.equal( [ 'foo' ] );
+			expect( ctx.getItem( 1 ).getAttribute( 'foo' ) ).to.equal( 1 );
+			expect( Array.from( ctx.getItem( 2 ).getAttributeKeys() ) ).to.deep.equal( [ 'align' ] );
+			expect( ctx.getItem( 2 ).getAttribute( 'align' ) ).to.equal( 'left' );
+		} );
+
+		it( 'creates context based on a text node', () => {
+			const ctx = new SchemaContext( root.getChild( 0 ).getChild( 0 ).getChild( 0 ) );
+
+			expect( ctx.length ).to.equal( 4 );
+
+			expect( Array.from( ctx.getNames() ) ).to.deep.equal( [ '$root', 'blockQuote', 'paragraph', '$text' ] );
+
+			expect( Array.from( ctx.getItem( 3 ).getAttributeKeys() ).sort() ).to.deep.equal( [ 'bold', 'italic' ] );
+			expect( ctx.getItem( 3 ).getAttribute( 'bold' ) ).to.be.true;
+		} );
+
+		it( 'creates context based on a position', () => {
+			const pos = Position.createAt( root.getChild( 0 ).getChild( 0 ) );
+			const ctx = new SchemaContext( pos );
+
+			expect( ctx.length ).to.equal( 3 );
+
+			expect( Array.from( ctx.getNames() ) ).to.deep.equal( [ '$root', 'blockQuote', 'paragraph' ] );
+
+			expect( Array.from( ctx.getItem( 2 ).getAttributeKeys() ).sort() ).to.deep.equal( [ 'align' ] );
+		} );
+	} );
+
+	describe( 'length', () => {
+		it( 'gets the number of items', () => {
+			const ctx = new SchemaContext( [ 'a', 'b', 'c' ] );
+
+			expect( ctx.length ).to.equal( 3 );
+		} );
+	} );
+
+	describe( 'last', () => {
+		it( 'gets the last item', () => {
+			const ctx = new SchemaContext( [ 'a', 'b', 'c' ] );
+
+			expect( ctx.last ).to.be.an( 'object' );
+			expect( ctx.last.name ).to.equal( 'c' );
+		} );
+	} );
+
+	describe( 'Symbol.iterator', () => {
+		it( 'exists', () => {
+			const ctx = new SchemaContext( [ 'a', 'b', 'c' ] );
+
+			expect( ctx[ Symbol.iterator ] ).to.be.a( 'function' );
+			expect( Array.from( ctx ).map( item => item.name ) ).to.deep.equal( [ 'a', 'b', 'c' ] );
+		} );
+	} );
+
+	describe( 'getItem()', () => {
+		it( 'returns item by index', () => {
+			const ctx = new SchemaContext( [ 'a', 'b', 'c' ] );
+
+			expect( ctx.getItem( 1 ) ).to.be.an( 'object' );
+			expect( ctx.getItem( 1 ).name ).to.equal( 'b' );
+		} );
+
+		it( 'returns undefined if index exceeds the range', () => {
+			const ctx = new SchemaContext( [ 'a', 'b', 'c' ] );
+
+			expect( ctx.getItem( 3 ) ).to.be.undefined;
+		} );
+	} );
+
+	describe( 'getNames()', () => {
+		it( 'returns an iterator', () => {
+			const ctx = new SchemaContext( [ 'a', 'b', 'c' ] );
+
+			expect( ctx.getNames().next ).to.be.a( 'function' );
+		} );
+
+		it( 'returns an iterator which returns all item names', () => {
+			const ctx = new SchemaContext( [ 'a', 'b', 'c' ] );
+
+			expect( Array.from( ctx.getNames() ) ).to.deep.equal( [ 'a', 'b', 'c' ] );
+		} );
+	} );
+
+	describe( 'matchEnd()', () => {
+		it( 'returns true if the end of the context matches the query - 1 item', () => {
+			const ctx = new SchemaContext( [ 'foo', 'bar', 'bom', 'dom' ] );
+
+			expect( ctx.matchEnd( 'dom' ) ).to.be.true;
+		} );
+
+		it( 'returns true if the end of the context matches the query - 2 items', () => {
+			const ctx = new SchemaContext( [ 'foo', 'bar', 'bom', 'dom' ] );
+
+			expect( ctx.matchEnd( 'bom dom' ) ).to.be.true;
+		} );
+
+		it( 'returns true if the end of the context matches the query - full match of 3 items', () => {
+			const ctx = new SchemaContext( [ 'foo', 'bar', 'bom' ] );
+
+			expect( ctx.matchEnd( 'foo bar bom' ) ).to.be.true;
+		} );
+
+		it( 'returns true if the end of the context matches the query - full match of 1 items', () => {
+			const ctx = new SchemaContext( [ 'foo' ] );
+
+			expect( ctx.matchEnd( 'foo' ) ).to.be.true;
+		} );
+
+		it( 'returns true if not only the end of the context matches the query', () => {
+			const ctx = new SchemaContext( [ 'foo', 'foo', 'foo', 'foo' ] );
+
+			expect( ctx.matchEnd( 'foo foo' ) ).to.be.true;
+		} );
+
+		it( 'returns false if query matches the middle of the context', () => {
+			const ctx = new SchemaContext( [ 'foo', 'bar', 'bom', 'dom' ] );
+
+			expect( ctx.matchEnd( 'bom' ) ).to.be.false;
+		} );
+
+		it( 'returns false if query matches the start of the context', () => {
+			const ctx = new SchemaContext( [ 'foo', 'bar', 'bom', 'dom' ] );
+
+			expect( ctx.matchEnd( 'foo' ) ).to.be.false;
+		} );
+
+		it( 'returns false if query does not match', () => {
+			const ctx = new SchemaContext( [ 'foo', 'bar', 'bom', 'dom' ] );
+
+			expect( ctx.matchEnd( 'dom bar' ) ).to.be.false;
+		} );
+
+		it( 'returns false if query is longer than context', () => {
+			const ctx = new SchemaContext( [ 'foo' ] );
+
+			expect( ctx.matchEnd( 'bar', 'foo' ) ).to.be.false;
+		} );
+	} );
 } );

+ 6 - 22
packages/ckeditor5-engine/tests/model/utils/deletecontent.js

@@ -468,38 +468,22 @@ describe( 'DataController utils', () => {
 
 					schema.on( 'checkAttribute', ( evt, args ) => {
 						const ctx = args[ 0 ];
-						const ctxItem = ctx[ ctx.length - 1 ];
-						const ctxParent = ctx[ ctx.length - 2 ];
-						const ctxParent2 = ctx[ ctx.length - 3 ];
 						const attributeName = args[ 1 ];
 
-						// allow 'a' and 'b' in paragraph>$text
-						if (
-							ctxItem.name == '$text' &&
-							ctxParent.name == 'paragraph' &&
-							[ 'a', 'b' ].includes( attributeName )
-						) {
+						// Allow 'a' and 'b' on paragraph>$text.
+						if ( ctx.matchEnd( 'paragraph $text' ) && [ 'a', 'b' ].includes( attributeName ) ) {
 							evt.stop();
 							evt.return = true;
 						}
 
-						// allow 'b' and 'c' in pchild>$text
-						if (
-							ctxItem.name == '$text' &&
-							ctxParent.name == 'pchild' &&
-							[ 'b', 'c' ].includes( attributeName )
-						) {
+						// Allow 'b' and 'c' in pchild>$text.
+						if ( ctx.matchEnd( 'pchild $text' ) && [ 'b', 'c' ].includes( attributeName ) ) {
 							evt.stop();
 							evt.return = true;
 						}
 
-						// disallow 'c' in pchild>pchild>$text
-						if (
-							ctxItem.name == '$text' &&
-							ctxParent.name == 'pchild' &&
-							ctxParent2.name == 'pchild' &&
-							attributeName == 'c'
-						) {
+						// Disallow 'c' on pchild>pchild>$text.
+						if ( ctx.matchEnd( 'pchild pchild $text' ) && attributeName == 'c' ) {
 							evt.stop();
 							evt.return = false;
 						}

+ 10 - 30
packages/ckeditor5-engine/tests/model/utils/insertcontent.js

@@ -274,11 +274,11 @@ 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 context = args[ 0 ];
+					const ctx = args[ 0 ];
 					const child = args[ 1 ];
 					const childRule = model.schema.getRule( child );
 
-					if ( childRule.name == 'paragraph' && context[ context.length - 1 ].name == '$root' ) {
+					if ( childRule.name == 'paragraph' && ctx.matchEnd( '$root' ) ) {
 						evt.stop();
 						evt.return = false;
 					}
@@ -620,48 +620,28 @@ describe( 'DataController utils', () => {
 
 				schema.on( 'checkAttribute', ( evt, args ) => {
 					const ctx = args[ 0 ];
-					const ctxItem = ctx[ ctx.length - 1 ];
-					const ctxParent = ctx[ ctx.length - 2 ];
-					const ctxParent2 = ctx[ ctx.length - 3 ];
-					const ctxParent3 = ctx[ ctx.length - 4 ];
 					const attributeName = args[ 1 ];
 
-					// 'b' in paragraph>$text
-					if (
-						ctxItem.name == '$text' &&
-						ctxParent.name == 'paragraph' &&
-						attributeName == 'b'
-					) {
+					// Allow 'b' on paragraph>$text.
+					if ( ctx.matchEnd( 'paragraph $text' ) && attributeName == 'b' ) {
 						evt.stop();
 						evt.return = true;
 					}
 
-					// 'b' in paragraph>element>$text
-					if (
-						ctxItem.name == '$text' &&
-						ctxParent.name == 'element' && ctxParent2.name == 'paragraph' &&
-						attributeName == 'b'
-					) {
+					// Allow 'b' on paragraph>element>$text.
+					if ( ctx.matchEnd( 'paragraph element $text' ) && attributeName == 'b' ) {
 						evt.stop();
 						evt.return = true;
 					}
 
-					// 'a' and 'b' in heading1>element>$text
-					if (
-						ctxItem.name == '$text' &&
-						ctxParent.name == 'element' && ctxParent2 == 'heading1' &&
-						[ 'a', 'b' ].includes( attributeName )
-					) {
+					// Allow 'a' and 'b' on heading1>element>$text.
+					if ( ctx.matchEnd( 'heading1 element $text' ) && [ 'a', 'b' ].includes( attributeName ) ) {
 						evt.stop();
 						evt.return = true;
 					}
 
-					// 'b' in element>table>td>$text
-					if (
-						ctxItem.name == '$text' &&
-						ctxParent.name == 'td' && ctxParent2.name == 'table' && ctxParent3.name == 'element' &&
-						attributeName == 'b'
-					) {
+					// Allow 'b' on element>table>td>$text.
+					if ( ctx.matchEnd( 'element table td $text' ) && attributeName == 'b' ) {
 						evt.stop();
 						evt.return = true;
 					}