8
0
Quellcode durchsuchen

Introduced core.Schema.

Szymon Cofalik vor 9 Jahren
Ursprung
Commit
71b1c38a0e

+ 210 - 0
packages/ckeditor5-engine/src/schema.js

@@ -0,0 +1,210 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import clone from './lib/lodash/clone.js';
+import CKEditorError from './ckeditorerror.js';
+
+/**
+ * @class core.Schema
+ */
+export default class Schema {
+	constructor() {
+		this._items = {};
+		this._baseChains = {};
+
+		this.registerItem( 'inline', null );
+		this.registerItem( 'block', null );
+		this.registerItem( 'root', null );
+
+		this.allow( { name: 'block', inside: 'root' } );
+		this.allow( { name: 'inline', inside: 'block' } );
+	}
+
+	allow( query ) {
+		this._getItem( query.name ).addAllowed( query.inside, query.attribute );
+	}
+
+	disallow( query ) {
+		this._getItem( query.name ).addDisallowed( query.inside, query.attribute );
+	}
+
+	_getItem( itemName ) {
+		if ( !this.hasItem( itemName ) ) {
+			/**
+			 * Item with specified name does not exist in schema.
+			 *
+			 * @error schema-no-item
+			 */
+			throw new CKEditorError( 'schema-no-item: Item with specified name does not exist in schema.' );
+		}
+
+		return this._items[ itemName ];
+	}
+
+	hasItem( itemName ) {
+		return !!this._items[ itemName ];
+	}
+
+	registerItem( itemName, baseOn ) {
+		if ( this.hasItem( itemName ) ) {
+			/**
+			 * Item with specified name already exists in schema.
+			 *
+			 * @error schema-item-exists
+			 */
+			throw new CKEditorError( 'schema-item-exists: Item with specified name already exists in schema.' );
+		}
+
+		if ( !!baseOn && !this.hasItem( baseOn ) ) {
+			/**
+			 * Item with specified name does not exist in schema.
+			 *
+			 * @error schema-no-item
+			 */
+			throw new CKEditorError( 'schema-no-item: Item with specified name does not exist in schema.' );
+		}
+
+		this._items[ itemName ] = new SchemaItem( this );
+		this._baseChains[ itemName ] = this.hasItem( baseOn ) ? this._baseChains[ baseOn ].concat( itemName ) : [ itemName ];
+	}
+
+	checkAtPosition( query, position ) {
+		if ( !this.hasItem( query.name ) ) {
+			return false;
+		}
+
+		const path = Schema._makeItemsPathFromPosition( position );
+
+		return this.checkForPath( query, path );
+	}
+
+	checkForPath( query, path ) {
+		if ( !this.hasItem( query.name ) ) {
+			return false;
+		}
+
+		path = ( typeof path === 'string' ) ? path.split( ' ' ) : path;
+
+		const schemaItems = this._baseChains[ query.name ].map( ( name ) => {
+			return this._getItem( name );
+		} );
+
+		// If there is matching disallow path, this query is not valid with schema.
+		for ( let schemaItem of schemaItems ) {
+			if ( schemaItem._hasMatchingPath( 'DISALLOW', path, query.attribute ) ) {
+				return false;
+			}
+		}
+
+		// There are no disallow path that matches query.
+		// If there is any allow path that matches query, this query is valid with schema.
+		for ( let schemaItem of schemaItems ) {
+			if ( schemaItem._hasMatchingPath( 'ALLOW', path, query.attribute ) ) {
+				return true;
+			}
+		}
+
+		// There are no allow paths that matches query. The query is not valid with schema.
+		return false;
+	}
+
+	static _makeItemsPathFromPosition( position ) {
+		let path = [];
+		let parent = position.parent;
+
+		while ( parent !== null ) {
+			path.push( parent.name );
+			parent = parent.parent;
+		}
+
+		return path;
+	}
+}
+
+export class SchemaItem {
+	constructor( schema ) {
+		if ( !schema ) {
+			/**
+			 * Schema item must have schema instance.
+			 *
+			 * @error schema-item-no-schema
+			 */
+			throw new CKEditorError( 'schema-item-no-schema: Schema item must have schema instance.' );
+		}
+
+		this._schema = schema;
+		this._allowed = [];
+		this._disallowed = [];
+	}
+
+	addAllowed( path, attribute ) {
+		this._addPath( '_allowed', path, attribute );
+	}
+
+	addDisallowed( path, attribute ) {
+		this._addPath( '_disallowed', path, attribute );
+	}
+
+	_addPath( member, path, attribute ) {
+		if ( typeof path === 'string' ) {
+			path = path.split( ' ' );
+		}
+
+		path = path.slice();
+
+		this[ member ].push( { path, attribute } );
+	}
+
+	_getPaths( type, attribute ) {
+		let source = type === 'ALLOW' ? this._allowed : this._disallowed;
+		let paths = [];
+
+		for ( let item of source ) {
+			if ( item.attribute === attribute ) {
+				paths.push( item.path.slice() );
+			}
+		}
+
+		return paths;
+	}
+
+	_hasMatchingPath( type, checkPath, attribute ) {
+		const itemPaths = this._getPaths( type, attribute );
+
+		checkPath = checkPath.slice();
+
+		for ( let itemPath of itemPaths ) {
+			for ( let checkName of checkPath ) {
+				let baseChain = this._schema._baseChains[ checkName ];
+
+				if ( baseChain.indexOf( itemPath[ 0 ] ) > -1 ) {
+					itemPath.shift();
+				}
+			}
+
+			if ( itemPath.length === 0 ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Custom toJSON method to solve child-parent circular dependencies.
+	 *
+	 * @returns {Object} Clone of this object with the parent property replaced with its name.
+	 */
+	toJSON() {
+		const json = clone( this );
+
+		// Due to circular references we need to remove parent reference.
+		json._schema = '[treeModel.Schema]';
+
+		return json;
+	}
+}

+ 207 - 0
packages/ckeditor5-engine/tests/schema/schema.js

@@ -0,0 +1,207 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Schema from '/ckeditor5/core/schema.js';
+import { SchemaItem as SchemaItem } from '/ckeditor5/core/schema.js';
+import Document from '/ckeditor5/core/treemodel/document.js';
+import Element from '/ckeditor5/core/treemodel/element.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
+
+let schema;
+
+beforeEach( () => {
+	schema = new Schema();
+} );
+
+describe( 'constructor', () => {
+	it( 'should register base items: inline, block, root', () => {
+		sinon.spy( Schema.prototype, 'registerItem' );
+
+		schema = new Schema();
+
+		expect( schema.registerItem.calledWithExactly( 'inline', null ) );
+		expect( schema.registerItem.calledWithExactly( 'block', null ) );
+		expect( schema.registerItem.calledWithExactly( 'root', null ) );
+
+		Schema.prototype.registerItem.restore();
+	} );
+
+	it( 'should allow inline in block', () => {
+		expect( schema.checkForPath( { name: 'inline' }, [ 'block' ] ) ).to.be.true;
+	} );
+
+	it( 'should allow block in root', () => {
+		expect( schema.checkForPath( { name: 'block' }, [ 'root' ] ) ).to.be.true;
+	} );
+} );
+
+describe( 'registerItem', () => {
+	it( 'should register in schema item under given name', () => {
+		schema.registerItem( 'new' );
+
+		expect( schema.hasItem( 'new' ) ).to.be.true;
+	} );
+
+	it( 'should build correct base chains', () => {
+		schema.registerItem( 'first' );
+		schema.registerItem( 'secondA', 'first' );
+		schema.registerItem( 'secondB', 'first' );
+		schema.registerItem( 'third', 'secondA' );
+
+		expect( schema._baseChains.first ).to.deep.equal( [ 'first' ] );
+		expect( schema._baseChains.secondA ).to.deep.equal( [ 'first', 'secondA' ] );
+		expect( schema._baseChains.secondB ).to.deep.equal( [ 'first', 'secondB' ] );
+		expect( schema._baseChains.third ).to.deep.equal( [ 'first', 'secondA', 'third' ] );
+	} );
+
+	it( 'should make registered item inherit allows from base item', () => {
+		schema.registerItem( 'div', 'block' );
+
+		expect( schema.checkForPath( { name: 'div' }, [ 'root' ] ) ).to.be.true;
+	} );
+
+	it( 'should throw if item with given name has already been registered in schema', () => {
+		schema.registerItem( 'new' );
+
+		expect( () => {
+			schema.registerItem( 'new' );
+		} ).to.throw( CKEditorError, /schema-item-exists/ );
+	} );
+
+	it( 'should throw if base item has not been registered in schema', () => {
+		expect( () => {
+			schema.registerItem( 'new', 'old' );
+		} ).to.throw( CKEditorError, /schema-no-item/ );
+	} );
+} );
+
+describe( 'hasItem', () => {
+	it( 'should return true if given item name has been registered in schema', () => {
+		expect( schema.hasItem( 'block' ) ).to.be.true;
+	} );
+
+	it( 'should return false if given item name has not been registered in schema', () => {
+		expect( schema.hasItem( 'new' ) ).to.be.false;
+	} );
+} );
+
+describe( '_getItem', () => {
+	it( 'should return SchemaItem registered under given name', () => {
+		schema.registerItem( 'new' );
+
+		let item = schema._getItem( 'new' );
+
+		expect( item ).to.be.instanceof( SchemaItem );
+	} );
+
+	it( 'should throw if there is no item registered under given name', () => {
+		expect( () => {
+			schema._getItem( 'new' );
+		} ).to.throw( CKEditorError, /schema-no-item/ );
+	} );
+} );
+
+describe( 'allow', () => {
+	it( 'should add passed query to allowed in schema', () => {
+		schema.registerItem( 'p', 'block' );
+		schema.registerItem( 'div', 'block' );
+
+		expect( schema.checkForPath( { name: 'p' }, [ 'div' ] ) ).to.be.false;
+
+		schema.allow( { name: 'p', inside: 'div' } );
+
+		expect( schema.checkForPath( { name: 'p' }, [ 'div' ] ) ).to.be.true;
+	} );
+} );
+
+describe( 'disallow', () => {
+	it( 'should add passed query to disallowed in schema', () => {
+		schema.registerItem( 'p', 'block' );
+		schema.registerItem( 'div', 'block' );
+
+		schema.allow( { name: 'block', attribute: 'bold', inside: 'div' } );
+
+		expect( schema.checkForPath( { name: 'p', attribute: 'bold' }, [ 'div' ] ) ).to.be.true;
+
+		schema.disallow( { name: 'p', attribute: 'bold', inside: 'div' } );
+
+		expect( schema.checkForPath( { name: 'p', attribute: 'bold' }, [ 'div' ] ) ).to.be.false;
+	} );
+} );
+
+describe( 'checkAtPosition', () => {
+	let doc, root;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+
+		root.insertChildren( 0, [
+			new Element( 'div' ),
+			new Element( 'header' ),
+			new Element( 'p' )
+		] );
+
+		schema.registerItem( 'div', 'block' );
+		schema.registerItem( 'header', 'block' );
+		schema.registerItem( 'p', 'block' );
+
+		schema.allow( { name: 'p', inside: 'div' } );
+		schema.allow( { name: 'header', inside: 'div' } );
+		schema.allow( { name: 'inline', attribute: 'bold', inside: 'block' } );
+
+		schema.disallow( { name: 'inline', attribute: 'bold', inside: 'header' } );
+	} );
+
+	it( 'should return true if given element is allowed by schema at given position', () => {
+		// Block should be allowed in root.
+		expect( schema.checkAtPosition( { name: 'block' }, new Position( root, [ 0 ] ) ) ).to.be.true;
+
+		// P is block and block should be allowed in root.
+		expect( schema.checkAtPosition( { name: 'p' }, new Position( root, [ 0 ] ) ) ).to.be.true;
+
+		// P is allowed in DIV by the set rule.
+		expect( schema.checkAtPosition( { name: 'p' }, new Position( root, [ 0, 0 ] ) ) ).to.be.true;
+
+		// Inline is allowed in any block and is allowed with attribute bold.
+		// We do not check if it is allowed in header, because it is disallowed by the set rule.
+		expect( schema.checkAtPosition( { name: 'inline' }, new Position( root, [ 0, 0 ] ) ) ).to.be.true;
+		expect( schema.checkAtPosition( { name: 'inline' }, new Position( root, [ 2, 0 ] ) ) ).to.be.true;
+		expect( schema.checkAtPosition( { name: 'inline', attribute: 'bold' }, new Position( root, [ 0, 0 ] ) ) ).to.be.true;
+		expect( schema.checkAtPosition( { name: 'inline', attribute: 'bold' }, new Position( root, [ 2, 0 ] ) ) ).to.be.true;
+
+		// Header is allowed in DIV.
+		expect( schema.checkAtPosition( { name: 'header' }, new Position( root, [ 0, 0 ] ) ) ).to.be.true;
+	} );
+
+	it( 'should return false if given element is not allowed by schema at given position', () => {
+		// Inline is not allowed in root.
+		expect( schema.checkAtPosition( { name: 'inline' }, new Position( root, [ 0 ] ) ) ).to.be.false;
+
+		// P with attribute is not allowed anywhere.
+		expect( schema.checkAtPosition( { name: 'p', attribute: 'bold' }, new Position( root, [ 0 ] ) ) ).to.be.false;
+		expect( schema.checkAtPosition( { name: 'p', attribute: 'bold' }, new Position( root, [ 0, 0 ] ) ) ).to.be.false;
+
+		// Bold text is not allowed in header
+		expect( schema.checkAtPosition( { name: 'inline', attribute: 'bold' }, new Position( root, [ 1, 0 ] ) ) ).to.be.false;
+	} );
+
+	it( 'should return false if given element is not registered in schema', () => {
+		expect( schema.checkAtPosition( { name: 'new' }, new Position( root, [ 0 ] ) ) ).to.be.false;
+	} );
+} );
+
+describe( 'checkForPath', () => {
+	it( 'should return false if given element is not registered in schema', () => {
+		expect( schema.checkForPath( { name: 'new' }, 'div header' ) ).to.be.false;
+	} );
+
+	it( 'should handle path given as string', () => {
+		expect( schema.checkForPath( { name: 'inline' }, 'block root' ) ).to.be.true;
+	} );
+} );

+ 169 - 0
packages/ckeditor5-engine/tests/schema/schemaitem.js

@@ -0,0 +1,169 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Schema from '/ckeditor5/core/schema.js';
+import { SchemaItem as SchemaItem } from '/ckeditor5/core/schema.js';
+import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
+
+let schema, item;
+
+beforeEach( () => {
+	schema = new Schema();
+
+	schema.registerItem( 'p', 'block' );
+	schema.registerItem( 'header', 'block' );
+	schema.registerItem( 'div', 'block' );
+	schema.registerItem( 'html', 'block' );
+	schema.registerItem( 'span', 'inline' );
+	schema.registerItem( 'image', 'inline' );
+
+	item = new SchemaItem( schema );
+} );
+
+describe( 'constructor', () => {
+	it( 'should create empty schema item', () => {
+		let item = new SchemaItem( schema );
+
+		expect( item._disallowed ).to.deep.equal( [] );
+		expect( item._allowed ).to.deep.equal( [] );
+	} );
+
+	it( 'should throw if no schema was passed', () => {
+		expect( () => {
+			new SchemaItem();
+		} ).to.throw( CKEditorError, /schema-item-no-schema/ );
+	} );
+} );
+
+describe( 'addAllowed', () => {
+	it( 'should add paths to the item as copies of passed array', () => {
+		let path1 = [ 'div', 'header' ];
+		let path2 = [ 'p' ];
+
+		item.addAllowed( path1 );
+		item.addAllowed( path2 );
+
+		let paths = item._getPaths( 'ALLOW' );
+
+		expect( paths.length ).to.equal( 2 );
+
+		expect( paths[ 0 ] ).not.to.equal( path1 );
+		expect( paths[ 1 ] ).not.to.equal( path2 );
+
+		expect( paths[ 0 ] ).to.deep.equal( [ 'div', 'header' ] );
+		expect( paths[ 1 ] ).to.deep.equal( [ 'p' ] );
+	} );
+
+	it( 'should accept paths as string with element names separated with space', () => {
+		item.addAllowed( 'div header' );
+
+		let paths = item._getPaths( 'ALLOW' );
+
+		expect( paths[ 0 ] ).to.deep.equal( [ 'div', 'header' ] );
+	} );
+
+	it( 'should group paths by attribute', () => {
+		item.addAllowed( 'p', 'bold' );
+		item.addAllowed( 'div' );
+		item.addAllowed( 'header', 'bold' );
+
+		let pathsWithNoAttribute = item._getPaths( 'ALLOW' );
+		let pathsWithBoldAttribute = item._getPaths( 'ALLOW', 'bold' );
+
+		expect( pathsWithNoAttribute.length ).to.equal( 1 );
+		expect( pathsWithNoAttribute[ 0 ] ).to.deep.equal( [ 'div' ] );
+
+		expect( pathsWithBoldAttribute.length ).to.equal( 2 );
+		expect( pathsWithBoldAttribute[ 0 ] ).to.deep.equal( [ 'p' ] );
+		expect( pathsWithBoldAttribute[ 1 ] ).to.deep.equal( [ 'header' ] );
+	} );
+} );
+
+describe( 'addDisallowed', () => {
+	it( 'should add paths to the item as copies of passed array', () => {
+		let path1 = [ 'div', 'header' ];
+		let path2 = [ 'p' ];
+
+		item.addDisallowed( path1 );
+		item.addDisallowed( path2 );
+
+		let paths = item._getPaths( 'DISALLOW' );
+
+		expect( paths.length ).to.equal( 2 );
+
+		expect( paths[ 0 ] ).not.to.equal( path1 );
+		expect( paths[ 1 ] ).not.to.equal( path2 );
+
+		expect( paths[ 0 ] ).to.deep.equal( [ 'div', 'header' ] );
+		expect( paths[ 1 ] ).to.deep.equal( [ 'p' ] );
+	} );
+
+	it( 'should accept paths as string with element names separated with space', () => {
+		item.addDisallowed( 'div header' );
+
+		let paths = item._getPaths( 'DISALLOW' );
+
+		expect( paths[ 0 ] ).to.deep.equal( [ 'div', 'header' ] );
+	} );
+
+	it( 'should group paths by attribute', () => {
+		item.addDisallowed( 'p', 'bold' );
+		item.addDisallowed( 'div' );
+		item.addDisallowed( 'header', 'bold' );
+
+		let pathsWithNoAttribute = item._getPaths( 'DISALLOW' );
+		let pathsWithBoldAttribute = item._getPaths( 'DISALLOW', 'bold' );
+
+		expect( pathsWithNoAttribute.length ).to.equal( 1 );
+		expect( pathsWithNoAttribute[ 0 ] ).to.deep.equal( [ 'div' ] );
+
+		expect( pathsWithBoldAttribute.length ).to.equal( 2 );
+		expect( pathsWithBoldAttribute[ 0 ] ).to.deep.equal( [ 'p' ] );
+		expect( pathsWithBoldAttribute[ 1 ] ).to.deep.equal( [ 'header' ] );
+	} );
+} );
+
+describe( '_hasMatchingPath', () => {
+	it( 'should return true if there is at least one allowed path that matches query path', () => {
+		item.addAllowed( 'div header' );
+		item.addAllowed( 'image' );
+
+		expect( item._hasMatchingPath( 'ALLOW', [ 'div', 'header' ] ) ).to.be.true;
+		expect( item._hasMatchingPath( 'ALLOW', [ 'html', 'div', 'header' ] ) ).to.be.true;
+		expect( item._hasMatchingPath( 'ALLOW', [ 'div', 'header', 'span' ] ) ).to.be.true;
+		expect( item._hasMatchingPath( 'ALLOW', [ 'html', 'div', 'p', 'header', 'span' ] ) ).to.be.true;
+	} );
+
+	it( 'should return false if there are no allowed paths that match query path', () => {
+		item.addAllowed( 'div p' );
+
+		expect( item._hasMatchingPath( 'ALLOW', [ 'p' ] ) ).to.be.false;
+		expect( item._hasMatchingPath( 'ALLOW', [ 'div' ] ) ).to.be.false;
+		expect( item._hasMatchingPath( 'ALLOW', [ 'p', 'div' ] ) ).to.be.false;
+	} );
+
+	it( 'should return true if there is at least one disallowed path that matches query path', () => {
+		item.addAllowed( 'div header' );
+		item.addDisallowed( 'p header' );
+
+		expect( item._hasMatchingPath( 'DISALLOW', [ 'html', 'div', 'p', 'header', 'span' ] ) ).to.be.true;
+	} );
+
+	it( 'should use only paths that are registered for given attribute', () => {
+		item.addAllowed( 'div p' );
+		item.addAllowed( 'div', 'bold' );
+		item.addAllowed( 'header' );
+		item.addDisallowed( 'header', 'bold' );
+
+		expect( item._hasMatchingPath( 'ALLOW', [ 'html', 'div', 'p' ]  ) ).to.be.true;
+		expect( item._hasMatchingPath( 'ALLOW', [ 'html', 'div' ] ) ).to.be.false;
+		expect( item._hasMatchingPath( 'ALLOW', [ 'html', 'div' ], 'bold' ) ).to.be.true;
+
+		expect( item._hasMatchingPath( 'DISALLOW', [ 'html', 'div', 'header' ] ) ).to.be.false;
+		expect( item._hasMatchingPath( 'DISALLOW', [ 'html', 'div', 'p', 'header', 'span' ], 'bold' ) ).to.be.true;
+	} );
+} );