Browse Source

Added support for getValidRanges() and checkAttributeInSelection().

Piotrek Koszuliński 8 years ago
parent
commit
e1a88625d3

+ 75 - 4
packages/ckeditor5-engine/src/model/schema.js

@@ -7,6 +7,8 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 
+import Range from './range';
+
 /**
  * @module engine/model/schema
  */
@@ -66,17 +68,17 @@ export default class Schema {
 	}
 
 	/**
-	 * @param {module:engine/model/node~Node|String} item
+	 * @param {module:engine/model/item~Item|SchemaContextItem|String} item
 	 */
 	getRule( item ) {
 		let itemName;
 
 		if ( typeof item == 'string' ) {
 			itemName = item;
-		} else if ( item.is && item.is( 'text' ) ) {
+		} else if ( item.is && ( item.is( 'text' ) || item.is( 'textProxy' ) ) ) {
 			itemName = '$text';
 		}
-		// Element or context item.
+		// Element or SchemaContextItem.
 		else {
 			itemName = item.name;
 		}
@@ -164,6 +166,74 @@ export default class Schema {
 	}
 
 	/**
+	 * Checks whether the attribute is allowed in selection:
+	 *
+	 * * if the selection is not collapsed, then checks if the attribute is allowed on any of nodes in that range,
+	 * * if the selection is collapsed, then checks if on the selection position there's a text with the
+	 * specified attribute allowed.
+	 *
+	 * @param {module:engine/model/selection~Selection} selection Selection which will be checked.
+	 * @param {String} attribute The name of the attribute to check.
+	 * @returns {Boolean}
+	 */
+	checkAttributeInSelection( selection, attribute ) {
+		if ( selection.isCollapsed ) {
+			// Check whether schema allows for a text with the attribute in the selection.
+			return this.checkAttribute( [ ...selection.getFirstPosition().getAncestors(), '$text' ], attribute );
+		} else {
+			const ranges = selection.getRanges();
+
+			// For all ranges, check nodes in them until you find a node that is allowed to have the attribute.
+			for ( const range of ranges ) {
+				for ( const value of range ) {
+					if ( this.checkAttribute( value.item, attribute ) ) {
+						// If we found a node that is allowed to have the attribute, return true.
+						return true;
+					}
+				}
+			}
+		}
+
+		// If we haven't found such node, return false.
+		return false;
+	}
+
+	/**
+	 * Transforms the given set ranges into a set of ranges where the given attribute is allowed (and can be applied).
+	 *
+	 * @param {Array.<module:engine/model/range~Range>} ranges Ranges to be validated.
+	 * @param {String} attribute The name of the attribute to check.
+	 * @returns {Array.<module:engine/model/range~Range>} Ranges in which the attribute is allowed.
+	 */
+	getValidRanges( ranges, attribute ) {
+		const validRanges = [];
+
+		for ( const range of ranges ) {
+			let last = range.start;
+			let from = range.start;
+			const to = range.end;
+
+			for ( const value of range.getWalker() ) {
+				if ( !this.checkAttribute( value.item, attribute ) ) {
+					if ( !from.isEqual( last ) ) {
+						validRanges.push( new Range( from, last ) );
+					}
+
+					from = value.nextPosition;
+				}
+
+				last = value.nextPosition;
+			}
+
+			if ( from && !from.isEqual( to ) ) {
+				validRanges.push( new Range( from, to ) );
+			}
+		}
+
+		return validRanges;
+	}
+
+	/**
 	 * Removes attributes disallowed the schema.
 	 *
 	 * @param {Iterable.<module:engine/model/node~Node>} nodes Nodes that will be filtered.
@@ -443,7 +513,8 @@ function mapContextItem( ctxItem ) {
 		};
 	} else {
 		return {
-			name: ctxItem.is( 'text' ) ? '$text' : ctxItem.name,
+			// '$text' means text nodes and text proxies.
+			name: ctxItem.is( 'element' ) ? ctxItem.name : '$text',
 
 			* getAttributeKeys() {
 				yield* ctxItem.getAttributeKeys();

+ 242 - 2
packages/ckeditor5-engine/tests/model/schema.js

@@ -10,10 +10,13 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import Model from '../../src/model/model';
 
 import Element from '../../src/model/element';
-import Position from '../../src/model/position';
 import Text from '../../src/model/text';
+import TextProxy from '../../src/model/textproxy';
+import Position from '../../src/model/position';
+import Range from '../../src/model/range';
+import Selection from '../../src/model/selection';
 
-import { setData, getData } from '../../src/dev-utils/model';
+import { setData, getData, stringify } from '../../src/dev-utils/model';
 
 import AttributeDelta from '../../src/model/delta/attributedelta';
 
@@ -483,6 +486,230 @@ describe( 'Schema', () => {
 		} );
 	} );
 
+	describe( 'checkAttributeInSelection()', () => {
+		const attribute = 'bold';
+		let model, doc, schema;
+
+		beforeEach( () => {
+			model = new Model();
+			doc = model.document;
+			doc.createRoot();
+
+			schema = model.schema;
+
+			schema.register( 'p', { inheritAllFrom: '$block' } );
+			schema.register( 'h1', { inheritAllFrom: '$block' } );
+			schema.register( 'img', { allowWhere: '$text' } );
+			schema.register( 'figure', {
+				allowIn: '$root',
+				allowAttributes: [ 'name', 'title' ]
+			} );
+
+			schema.on( 'checkAttribute', ( evt, args ) => {
+				const ctx = args[ 0 ];
+				const attributeName = args[ 1 ];
+
+				// Allow 'bold' on p>$text.
+				if ( ctx.matchEnd( 'p $text' ) && attributeName == 'bold' ) {
+					evt.stop();
+					evt.return = true;
+				}
+
+				// Allow 'bold' on $root>p.
+				if ( ctx.matchEnd( '$root p' ) && attributeName == 'bold' ) {
+					evt.stop();
+					evt.return = true;
+				}
+			}, { priority: 'high' } );
+		} );
+
+		describe( 'when selection is collapsed', () => {
+			it( 'should return true if characters with the attribute can be placed at caret position', () => {
+				setData( model, '<p>f[]oo</p>' );
+				expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.true;
+			} );
+
+			it( 'should return false if characters with the attribute cannot be placed at caret position', () => {
+				setData( model, '<h1>[]</h1>' );
+				expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.false;
+
+				setData( model, '[]' );
+				expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.false;
+			} );
+		} );
+
+		describe( 'when selection is not collapsed', () => {
+			it( 'should return true if there is at least one node in selection that can have the attribute', () => {
+				// Simple selection on a few characters.
+				setData( model, '<p>[foo]</p>' );
+				expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.true;
+
+				// Selection spans over characters but also include nodes that can't have attribute.
+				setData( model, '<p>fo[o<img />b]ar</p>' );
+				expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.true;
+
+				// Selection on whole root content. Characters in P can have an attribute so it's valid.
+				setData( model, '[<p>foo<img />bar</p><h1></h1>]' );
+				expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.true;
+
+				// Selection on empty P. P can have the attribute.
+				setData( model, '[<p></p>]' );
+				expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.true;
+			} );
+
+			it( 'should return false if there are no nodes in selection that can have the attribute', () => {
+				// Selection on DIV which can't have bold text.
+				setData( model, '[<h1></h1>]' );
+				expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.false;
+
+				// Selection on two images which can't be bold.
+				setData( model, '<p>foo[<img /><img />]bar</p>' );
+				expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.false;
+			} );
+
+			it( 'should return true when checking element with required attribute', () => {
+				setData( model, '[<figure name="figure"></figure>]' );
+				expect( schema.checkAttributeInSelection( doc.selection, 'title' ) ).to.be.true;
+			} );
+
+			it( 'should return true when checking element when attribute is already present', () => {
+				setData( model, '[<figure name="figure" title="title"></figure>]' );
+				expect( schema.checkAttributeInSelection( doc.selection, 'title' ) ).to.be.true;
+			} );
+		} );
+	} );
+
+	describe( 'getValidRanges()', () => {
+		const attribute = 'bold';
+		let model, doc, root, schema, ranges;
+
+		beforeEach( () => {
+			model = new Model();
+			doc = model.document;
+			schema = model.schema;
+			root = doc.createRoot();
+
+			schema.register( 'p', { inheritAllFrom: '$block' } );
+			schema.register( 'h1', { inheritAllFrom: '$block' } );
+			schema.register( 'img', {
+				allowWhere: '$text'
+			} );
+
+			schema.on( 'checkAttribute', ( evt, args ) => {
+				const ctx = args[ 0 ];
+				const attributeName = args[ 1 ];
+
+				// Allow 'bold' on p>$text.
+				if ( ctx.matchEnd( 'p $text' ) && attributeName == 'bold' ) {
+					evt.stop();
+					evt.return = true;
+				}
+
+				// Allow 'bold' on $root>p.
+				if ( ctx.matchEnd( '$root p' ) && attributeName == 'bold' ) {
+					evt.stop();
+					evt.return = true;
+				}
+			}, { priority: 'high' } );
+
+			setData( model, '<p>foo<img />bar</p>' );
+
+			ranges = [ Range.createOn( root.getChild( 0 ) ) ];
+		} );
+
+		it( 'should return unmodified ranges when attribute is allowed on each item (text is not allowed in img)', () => {
+			schema.extend( 'img', { allowAttributes: 'bold' } );
+
+			expect( schema.getValidRanges( ranges, attribute ) ).to.deep.equal( ranges );
+		} );
+
+		it( 'should return unmodified ranges when attribute is allowed on each item (text is allowed in img)', () => {
+			schema.extend( 'img', { allowAttributes: 'bold' } );
+			schema.extend( '$text', { allowIn: 'img' } );
+
+			expect( schema.getValidRanges( ranges, attribute ) ).to.deep.equal( ranges );
+		} );
+
+		it( 'should return two ranges when attribute is not allowed on one item', () => {
+			schema.extend( 'img', { allowAttributes: 'bold' } );
+			schema.extend( '$text', { allowIn: 'img' } );
+
+			setData( model, '[<p>foo<img>xxx</img>bar</p>]' );
+
+			const validRanges = schema.getValidRanges( doc.selection.getRanges(), attribute );
+			const sel = new Selection();
+			sel.setRanges( validRanges );
+
+			expect( stringify( root, sel ) ).to.equal( '[<p>foo<img>]xxx[</img>bar</p>]' );
+		} );
+
+		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 ];
+
+				// Allow 'bold' on img>$text.
+				if ( ctx.matchEnd( 'img $text' ) && attributeName == 'bold' ) {
+					evt.stop();
+					evt.return = true;
+				}
+			}, { priority: 'high' } );
+
+			setData( model, '[<p>foo<img>xxx</img>bar</p>]' );
+
+			const validRanges = schema.getValidRanges( doc.selection.getRanges(), attribute );
+			const sel = new Selection();
+			sel.setRanges( validRanges );
+
+			expect( stringify( root, sel ) ).to.equal( '[<p>foo]<img>[xxx]</img>[bar</p>]' );
+		} );
+
+		it( 'should not leak beyond the given ranges', () => {
+			setData( model, '<p>[foo<img></img>bar]x[bar<img></img>foo]</p>' );
+
+			const validRanges = schema.getValidRanges( doc.selection.getRanges(), attribute );
+			const sel = new Selection();
+			sel.setRanges( validRanges );
+
+			expect( stringify( root, sel ) ).to.equal( '<p>[foo]<img></img>[bar]x[bar]<img></img>[foo]</p>' );
+		} );
+
+		it( 'should correctly handle a range which ends in a disallowed position', () => {
+			schema.extend( '$text', { allowIn: 'img' } );
+
+			setData( model, '<p>[foo<img>bar]</img>bom</p>' );
+
+			const validRanges = schema.getValidRanges( doc.selection.getRanges(), attribute );
+			const sel = new Selection();
+			sel.setRanges( validRanges );
+
+			expect( stringify( root, sel ) ).to.equal( '<p>[foo]<img>bar</img>bom</p>' );
+		} );
+
+		it( 'should split range into two ranges and omit disallowed element', () => {
+			schema.on( 'checkAttribute', ( evt, args ) => {
+				const ctx = args[ 0 ];
+				const attributeName = args[ 1 ];
+
+				// Disallow 'bold' on p>img.
+				if ( ctx.matchEnd( 'p img' ) && attributeName == 'bold' ) {
+					evt.stop();
+					evt.return = false;
+				}
+			}, { priority: 'high' } );
+
+			const result = schema.getValidRanges( ranges, attribute );
+
+			expect( result ).to.length( 2 );
+			expect( result[ 0 ].start.path ).to.members( [ 0 ] );
+			expect( result[ 0 ].end.path ).to.members( [ 0, 3 ] );
+			expect( result[ 1 ].start.path ).to.members( [ 0, 4 ] );
+			expect( result[ 1 ].end.path ).to.members( [ 1 ] );
+		} );
+	} );
+
 	describe( 'removeDisallowedAttributes()', () => {
 		let model, doc, root;
 
@@ -1734,6 +1961,19 @@ describe( 'SchemaContext', () => {
 			expect( ctx.getItem( 3 ).getAttribute( 'bold' ) ).to.be.true;
 		} );
 
+		it( 'creates context based on a text proxy', () => {
+			const text = root.getChild( 0 ).getChild( 0 ).getChild( 0 );
+			const textProxy = new TextProxy( text, 0, 1 );
+			const ctx = new SchemaContext( textProxy );
+
+			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 );