diff --git a/src/model/schema.js b/src/model/schema.js index 249be0b77..ed8e6ba62 100644 --- a/src/model/schema.js +++ b/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.} + */ + [ 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 ] ); -} diff --git a/tests/conversion/buildviewconverter.js b/tests/conversion/buildviewconverter.js index e85cc189d..49293e595 100644 --- a/tests/conversion/buildviewconverter.js +++ b/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; // } diff --git a/tests/conversion/view-to-model-converters.js b/tests/conversion/view-to-model-converters.js index 805bd2a2f..4cb46ed01 100644 --- a/tests/conversion/view-to-model-converters.js +++ b/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; } diff --git a/tests/model/schema.js b/tests/model/schema.js index a0767691c..228099c94 100644 --- a/tests/model/schema.js +++ b/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; + } ); + } ); } ); diff --git a/tests/model/utils/deletecontent.js b/tests/model/utils/deletecontent.js index 12d7f4f6a..c0955cb09 100644 --- a/tests/model/utils/deletecontent.js +++ b/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; } diff --git a/tests/model/utils/insertcontent.js b/tests/model/utils/insertcontent.js index 020294481..d4b5735f7 100644 --- a/tests/model/utils/insertcontent.js +++ b/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; }