Sfoglia il codice sorgente

Merge pull request #10 from ckeditor/t/9

Renamed and simplified AttributeCommand.
Szymon Cofalik 9 anni fa
parent
commit
c1a6548eba

+ 50 - 0
packages/ckeditor5-core/src/command/helpers/getschemavalidranges.js

@@ -0,0 +1,50 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import TreeWalker from '../../../engine/model/treewalker.js';
+import Range from '../../../engine/model/range.js';
+
+/**
+ * Walks through given array of ranges and removes parts of them that are not allowed by passed schema to have the
+ * attribute set. This is done by breaking a range in two and omitting the not allowed part.
+ *
+ * @param {String} attribute Attribute key.
+ * @param {Array.<engine.model.Range>} ranges Ranges to be validated.
+ * @param {engine.model.Schema} schema Document schema.
+ * @returns {Array.<engine.model.Range>} Ranges without invalid parts.
+ */
+export default function getSchemaValidRanges( attribute, ranges, schema ) {
+	const validRanges = [];
+
+	for ( let range of ranges ) {
+		const walker = new TreeWalker( { boundaries: range, mergeCharacters: true } );
+		let step = walker.next();
+
+		let last = range.start;
+		let from = range.start;
+		let to = range.end;
+
+		while ( !step.done ) {
+			const name = step.value.item.name || '$text';
+
+			if ( !schema.check( { name: name, inside: last, attributes: attribute } ) ) {
+				if ( !from.isEqual( last ) ) {
+					validRanges.push( new Range( from, last ) );
+				}
+
+				from = walker.position;
+			}
+
+			last = walker.position;
+			step = walker.next();
+		}
+
+		if ( from && !from.isEqual( to ) ) {
+			validRanges.push( new Range( from, to ) );
+		}
+	}
+
+	return validRanges;
+}

+ 49 - 0
packages/ckeditor5-core/src/command/helpers/isattributeallowedinselection.js

@@ -0,0 +1,49 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import TreeWalker from '../../../engine/model/treewalker.js';
+
+/**
+ * Checks {@link engine.model.Document#schema} if attribute is allowed in selection:
+ * * if selection is on range, the command is enabled if any of nodes in that range can have bold,
+ * * if selection is collapsed, the command is enabled if text with bold is allowed in that node.
+ *
+ * @param {String} attribute Attribute key.
+ * @param {engine.model.Selection} selection Selection which ranges will be validate.
+ * @param {engine.model.Schema} schema Document schema.
+ * @returns {Boolean}
+ */
+export default function isAttributeAllowedInSelection( attribute, selection, schema ) {
+	if ( selection.isCollapsed ) {
+		// Check whether schema allows for a test with `attributeKey` in caret position.
+		return schema.check( { name: '$text', inside: selection.getFirstPosition(), attributes: attribute } );
+	} else {
+		const ranges = selection.getRanges();
+
+		// For all ranges, check nodes in them until you find a node that is allowed to have `attributeKey` attribute.
+		for ( let range of ranges ) {
+			const walker = new TreeWalker( { boundaries: range, mergeCharacters: true } );
+			let last = walker.position;
+			let step = walker.next();
+
+			// Walk the range.
+			while ( !step.done ) {
+				// If returned item does not have name property, it is a model.TextFragment.
+				const name = step.value.item.name || '$text';
+
+				if ( schema.check( { name: name, inside: last, attributes: attribute } ) ) {
+					// If we found a node that is allowed to have the attribute, return true.
+					return true;
+				}
+
+				last = walker.position;
+				step = walker.next();
+			}
+		}
+	}
+
+	// If we haven't found such node, return false.
+	return false;
+}

+ 12 - 86
packages/ckeditor5-core/src/command/attributecommand.js

@@ -4,13 +4,13 @@
  */
 
 import Command from './command.js';
-import TreeWalker from '../../engine/model/treewalker.js';
-import Range from '../../engine/model/range.js';
+import getSchemaValidRanges from './helpers/getschemavalidranges.js';
+import isAttributeAllowedInSelection from './helpers/isattributeallowedinselection.js';
 
 /**
- * An extension of basic {@link core.command.Command} class, which provides utilities for a command that sets a single
- * attribute on a text or element with value `true`. AttributeCommand uses {@link engine.model.Document#selection} to
- * decide which nodes (if any) should be changed, and applies or removes attributes from them.
+ * An extension of basic {@link core.command.Command} class, which provides utilities for a command that toggle a single
+ * attribute on a text or element with value `true`. ToggleAttributeCommand uses {@link engine.model.Document#selection}
+ * to decide which nodes (if any) should be changed, and applies or removes attributes from them.
  * See {@link engine.view.Converter#execute} for more.
  *
  * The command checks {@link engine.model.Document#schema} to decide if it should be enabled.
@@ -18,7 +18,7 @@ import Range from '../../engine/model/range.js';
  *
  * @memberOf core.command
  */
-export default class AttributeCommand extends Command {
+export default class ToggleAttributeCommand extends Command {
 	/**
 	 * @see core.command.Command
 	 * @param {core.editor.Editor} editor
@@ -30,7 +30,7 @@ export default class AttributeCommand extends Command {
 		/**
 		 * Attribute that will be set by the command.
 		 *
-		 * @member {String} core.command.AttributeCommand#attributeKey
+		 * @member {String} core.command.ToggleAttributeCommand#attributeKey
 		 */
 		this.attributeKey = attributeKey;
 
@@ -39,7 +39,7 @@ export default class AttributeCommand extends Command {
 		 * the command's attribute set. For range selection it means that all nodes inside have the attribute applied.
 		 *
 		 * @observable
-		 * @member {Boolean} core.command.AttributeCommand#value
+		 * @member {Boolean} core.command.ToggleAttributeCommand#value
 		 */
 		this.set( 'value', false );
 
@@ -49,47 +49,15 @@ export default class AttributeCommand extends Command {
 	}
 
 	/**
-	 * Checks {@link engine.model.Document#schema} to decide if the command should be enabled:
-	 * * if selection is on range, the command is enabled if any of nodes in that range can have bold,
-	 * * if selection is collapsed, the command is enabled if text with bold is allowed in that node.
+	 * Checks if {@link engine.model.Document#schema} allows to create attribute in {@link engine.model.Document#selection}
 	 *
 	 * @private
 	 * @returns {Boolean}
 	 */
 	_checkEnabled() {
-		const selection = this.editor.document.selection;
-		const schema = this.editor.document.schema;
-
-		if ( selection.isCollapsed ) {
-			// Check whether schema allows for a test with `attributeKey` in caret position.
-			return schema.check( { name: '$text', inside: selection.getFirstPosition(), attributes: this.attributeKey } );
-		} else {
-			const ranges = selection.getRanges();
-
-			// For all ranges, check nodes in them until you find a node that is allowed to have `attributeKey` attribute.
-			for ( let range of ranges ) {
-				const walker = new TreeWalker( { boundaries: range, mergeCharacters: true } );
-				let last = walker.position;
-				let step = walker.next();
-
-				// Walk the range.
-				while ( !step.done ) {
-					// If returned item does not have name property, it is a model.TextFragment.
-					const name = step.value.item.name || '$text';
-
-					if ( schema.check( { name: name, inside: last, attributes: this.attributeKey } ) ) {
-						// If we found a node that is allowed to have the attribute, return true.
-						return true;
-					}
-
-					last = walker.position;
-					step = walker.next();
-				}
-			}
-		}
+		const document = this.editor.document;
 
-		// If we haven't found such node, return false.
-		return false;
+		return isAttributeAllowedInSelection( this.attributeKey, document.selection, document.schema );
 	}
 
 	/**
@@ -125,7 +93,7 @@ export default class AttributeCommand extends Command {
 		} else {
 			// If selection has non-collapsed ranges, we change attribute on nodes inside those ranges.
 			document.enqueueChanges( () => {
-				const ranges = this._getSchemaValidRanges( selection.getRanges() );
+				const ranges = getSchemaValidRanges( this.attributeKey, selection.getRanges(), document.schema );
 
 				// Keep it as one undo step.
 				const batch = document.batch();
@@ -140,46 +108,4 @@ export default class AttributeCommand extends Command {
 			} );
 		}
 	}
-
-	/**
-	 * Walks through given array of ranges and removes parts of them that are not allowed by schema to have the
-	 * attribute set. This is done by breaking a range in two and omitting the not allowed part.
-	 *
-	 * @private
-	 * @param {Array.<engine.model.Range>} ranges Ranges to be validated.
-	 * @returns {Array.<engine.model.Range>} Ranges without invalid parts.
-	 */
-	_getSchemaValidRanges( ranges ) {
-		const validRanges = [];
-
-		for ( let range of ranges ) {
-			const walker = new TreeWalker( { boundaries: range, mergeCharacters: true } );
-			let step = walker.next();
-
-			let last = range.start;
-			let from = range.start;
-			let to = range.end;
-
-			while ( !step.done ) {
-				const name = step.value.item.name || '$text';
-
-				if ( !this.editor.document.schema.check( { name: name, inside: last, attributes: this.attributeKey } ) ) {
-					if ( !from.isEqual( last ) ) {
-						validRanges.push( new Range( from, last ) );
-					}
-
-					from = walker.position;
-				}
-
-				last = walker.position;
-				step = walker.next();
-			}
-
-			if ( from && !from.isEqual( to ) ) {
-				validRanges.push( new Range( from, to ) );
-			}
-		}
-
-		return validRanges;
-	}
 }

+ 0 - 264
packages/ckeditor5-core/tests/command/attributecommand.js

@@ -1,264 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import Editor from '/ckeditor5/core/editor/editor.js';
-import Document from '/ckeditor5/engine/model/document.js';
-import AttributeCommand from '/ckeditor5/core/command/attributecommand.js';
-import Text from '/ckeditor5/engine/model/text.js';
-import Range from '/ckeditor5/engine/model/range.js';
-import Position from '/ckeditor5/engine/model/position.js';
-import Element from '/ckeditor5/engine/model/element.js';
-import writer from '/ckeditor5/engine/model/writer.js';
-import { itemAt } from '/tests/engine/model/_utils/utils.js';
-
-let editor, command, modelDoc, root;
-
-const attrKey = 'bold';
-
-beforeEach( () => {
-	editor = new Editor();
-	editor.document = new Document();
-
-	modelDoc = editor.document;
-	root = modelDoc.createRoot();
-
-	command = new AttributeCommand( editor, attrKey );
-
-	modelDoc.schema.registerItem( 'p', '$block' );
-	modelDoc.schema.registerItem( 'h1', '$block' );
-	modelDoc.schema.registerItem( 'img', '$inline' );
-
-	// Allow block in "root" (DIV)
-	modelDoc.schema.allow( { name: '$block', inside: '$root' } );
-
-	// Bold text is allowed only in P.
-	modelDoc.schema.allow( { name: '$text', attributes: 'bold', inside: 'p' } );
-	modelDoc.schema.allow( { name: 'p', attributes: 'bold', inside: '$root' } );
-
-	// Disallow bold on image.
-	modelDoc.schema.disallow( { name: 'img', attributes: 'bold', inside: '$root' } );
-} );
-
-afterEach( () => {
-	command.destroy();
-} );
-
-describe( 'value', () => {
-	it( 'should be set to true or false basing on selection attribute', () => {
-		modelDoc.selection.setAttribute( attrKey, true );
-		expect( command.value ).to.be.true;
-
-		modelDoc.selection.removeAttribute( attrKey );
-		expect( command.value ).to.be.false;
-	} );
-} );
-
-describe( '_doExecute', () => {
-	let p;
-
-	beforeEach( () => {
-		let attrs = {};
-		attrs[ attrKey ] = true;
-
-		root.insertChildren( 0, [
-			new Element( 'p', [] , [
-				new Text( 'abc' ),
-				new Text( 'foobar', attrs ),
-				new Text( 'xyz' )
-			] ),
-			new Element( 'p' )
-		] );
-
-		p = root.getChild( 0 );
-	} );
-
-	it( 'should add attribute on selected nodes if the command value was false', () => {
-		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 5 ] ) ) );
-
-		expect( command.value ).to.be.false;
-
-		command._doExecute();
-
-		expect( command.value ).to.be.true;
-
-		expect( p.getChild( 0 ).hasAttribute( attrKey ) ).to.be.false;
-		expect( itemAt( p, 1 ).hasAttribute( attrKey ) ).to.be.true;
-		expect( itemAt( p, 2 ).hasAttribute( attrKey ) ).to.be.true;
-	} );
-
-	it( 'should remove attribute from selected nodes if the command value was true', () => {
-		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 3 ] ), new Position( root, [ 0, 6 ] ) ) );
-
-		expect( command.value ).to.be.true;
-
-		command._doExecute();
-
-		expect( command.value ).to.be.false;
-		expect( itemAt( p, 3 ).hasAttribute( attrKey ) ).to.be.false;
-		expect( itemAt( p, 4 ).hasAttribute( attrKey ) ).to.be.false;
-		expect( itemAt( p, 5 ).hasAttribute( attrKey ) ).to.be.false;
-	} );
-
-	it( 'should add attribute on selected nodes if execute parameter was set to true', () => {
-		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 7 ] ), new Position( root, [ 0, 10 ] ) ) );
-
-		expect( command.value ).to.be.true;
-
-		command._doExecute( true );
-
-		expect( command.value ).to.be.true;
-		expect( itemAt( p, 9 ).hasAttribute( attrKey ) ).to.be.true;
-	} );
-
-	it( 'should remove attribute on selected nodes if execute parameter was set to false', () => {
-		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 5 ] ) ) );
-
-		expect( command.value ).to.be.false;
-
-		command._doExecute( false );
-
-		expect( command.value ).to.be.false;
-		expect( itemAt( p, 3 ).hasAttribute( attrKey ) ).to.be.false;
-		expect( itemAt( p, 4 ).hasAttribute( attrKey ) ).to.be.false;
-	} );
-
-	it( 'should change selection attribute if selection is collapsed in non-empty parent', () => {
-		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 1 ] ) ) );
-
-		expect( command.value ).to.be.false;
-
-		command._doExecute();
-
-		expect( command.value ).to.be.true;
-		expect( modelDoc.selection.hasAttribute( 'bold' ) ).to.be.true;
-
-		command._doExecute();
-
-		expect( command.value ).to.be.false;
-		expect( modelDoc.selection.hasAttribute( 'bold' ) ).to.be.false;
-	} );
-
-	it( 'should not store attribute change on selection if selection is collapsed in non-empty parent', () => {
-		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 1 ] ) ) );
-		command._doExecute();
-
-		// It should not save that bold was executed at position ( root, [ 0, 1 ] ).
-
-		// Simulate clicking right arrow key by changing selection ranges.
-		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 2 ] ) ) ] );
-
-		// Get back to previous selection.
-		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 1 ] ) ) ] );
-
-		expect( command.value ).to.be.false;
-	} );
-
-	it( 'should change selection attribute and store it if selection is collapsed in empty parent', () => {
-		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 0 ] ) ) ] );
-
-		expect( command.value ).to.be.false;
-
-		command._doExecute();
-
-		expect( command.value ).to.be.true;
-		expect( modelDoc.selection.hasAttribute( 'bold' ) ).to.be.true;
-
-		// Attribute should be stored.
-		// Simulate clicking somewhere else in the editor.
-		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 2 ] ) ) ] );
-
-		expect( command.value ).to.be.false;
-
-		// Go back to where attribute was stored.
-		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 0 ] ) ) ] );
-
-		// Attribute should be restored.
-		expect( command.value ).to.be.true;
-
-		command._doExecute();
-
-		expect( command.value ).to.be.false;
-		expect( modelDoc.selection.hasAttribute( 'bold' ) ).to.be.false;
-	} );
-
-	it( 'should not apply attribute change where it would invalid schema', () => {
-		writer.insert( Position.createFromParentAndOffset( p, 3 ), new Element( 'image' ) );
-		writer.insert( Position.createFromParentAndOffset( p, 12 ), new Element( 'image' ) );
-
-		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 13 ] ) ) ] );
-
-		expect( command.isEnabled ).to.be.true;
-
-		command._doExecute();
-
-		let expectedHas = [ 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0 ];
-
-		let i = 0;
-
-		for ( let node of Range.createIn( p ).getItems( { singleCharacters: true } ) ) {
-			expect( node.hasAttribute( attrKey ) ).to.equal( !!expectedHas[ i++ ] );
-		}
-	} );
-} );
-
-describe( '_checkEnabled', () => {
-	beforeEach( () => {
-		root.insertChildren( 0, [
-			new Element( 'p', [], [
-				new Text( 'foo' ),
-				new Element( 'img' ),
-				new Element( 'img' ),
-				new Text( 'bar' )
-			] ),
-			new Element( 'h1' ),
-			new Element( 'p' )
-		] );
-	} );
-
-	describe( 'when selection is collapsed', () => {
-		it( 'should return true if characters with the attribute can be placed at caret position', () => {
-			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 1 ] ) ) ] );
-			expect( command._checkEnabled() ).to.be.true;
-		} );
-
-		it( 'should return false if characters with the attribute cannot be placed at caret position', () => {
-			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 0 ] ) ) ] );
-			expect( command._checkEnabled() ).to.be.false;
-
-			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 2 ] ) ) ] );
-			expect( command._checkEnabled() ).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.
-			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 0 ] ), new Position( root, [ 0, 3 ] ) ) ] );
-			expect( command._checkEnabled() ).to.be.true;
-
-			// Selection spans over characters but also include nodes that can't have attribute.
-			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 6 ] ) ) ] );
-			expect( command._checkEnabled() ).to.be.true;
-
-			// Selection on whole root content. Characters in P can have an attribute so it's valid.
-			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0 ] ), new Position( root, [ 3 ] ) ) ] );
-			expect( command._checkEnabled() ).to.be.true;
-
-			// Selection on empty P. P can have the attribute.
-			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 3 ] ) ) ] );
-			expect( command._checkEnabled() ).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.
-			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 1 ] ), new Position( root, [ 2 ] ) ) ] );
-			expect( command._checkEnabled() ).to.be.false;
-
-			// Selection on two images which can't be bold.
-			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 3 ] ), new Position( root, [ 0, 5 ] ) ) ] );
-			expect( command._checkEnabled() ).to.be.false;
-		} );
-	} );
-} );

+ 51 - 0
packages/ckeditor5-core/tests/command/helpers/getschemavalidranges.js

@@ -0,0 +1,51 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Document from '/ckeditor5/engine/model/document.js';
+import Range from '/ckeditor5/engine/model/range.js';
+import getSchemaValidRanges from '/ckeditor5/core/command/helpers/getschemavalidranges.js';
+import { setData } from '/tests/engine/_utils/model.js';
+
+describe( 'getSchemaValidRanges', () => {
+	const attribute = 'bold';
+	let document, root, schema, ranges;
+
+	beforeEach( () => {
+		document = new Document();
+		schema = document.schema;
+		root = document.createRoot();
+
+		schema.registerItem( 'p', '$block' );
+		schema.registerItem( 'h1', '$block' );
+		schema.registerItem( 'img', '$inline' );
+
+		// Allow bold on p
+		schema.allow( { name: '$text', attributes: 'bold', inside: 'p' } );
+		schema.allow( { name: 'p', attributes: 'bold', inside: '$root' } );
+
+		setData( document, '<p>foo<img />bar</p>' );
+		ranges = [ Range.createOn( root.getChild( 0 ) ) ];
+	} );
+
+	it( 'should return unmodified ranges when attribute is allowed on each element', () => {
+		// Allow bold on img
+		schema.allow( { name: 'img', attributes: 'bold', inside: 'p' } );
+
+		expect( getSchemaValidRanges( attribute, ranges, schema ) ).to.deep.equal( ranges );
+	} );
+
+	it( 'should split range into two ranges and omit disallowed element', () => {
+		// Disallow bold on img.
+		document.schema.disallow( { name: 'img', attributes: 'bold', inside: 'p' } );
+
+		const result = getSchemaValidRanges( attribute, ranges, schema );
+
+		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 ] );
+	} );
+} );

+ 74 - 0
packages/ckeditor5-core/tests/command/helpers/isattributeallowedinselection.js

@@ -0,0 +1,74 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Document from '/ckeditor5/engine/model/document.js';
+import isAttributeAllowedInSelection from '/ckeditor5/core/command/helpers/isattributeallowedinselection.js';
+import { setData } from '/tests/engine/_utils/model.js';
+
+describe( 'isAttributeAllowedInSelection', () => {
+	const attribute = 'bold';
+	let document, root;
+
+	beforeEach( () => {
+		document = new Document();
+		root = document.createRoot();
+
+		document.schema.registerItem( 'p', '$block' );
+		document.schema.registerItem( 'h1', '$block' );
+		document.schema.registerItem( 'img', '$inline' );
+
+		// Bold text is allowed only in P.
+		document.schema.allow( { name: '$text', attributes: 'bold', inside: 'p' } );
+		document.schema.allow( { name: 'p', attributes: 'bold', inside: '$root' } );
+
+		// Disallow bold on image.
+		document.schema.disallow( { name: 'img', attributes: 'bold', inside: '$root' } );
+	} );
+
+	describe( 'when selection is collapsed', () => {
+		it( 'should return true if characters with the attribute can be placed at caret position', () => {
+			setData( document, '<p>f[]oo</p>' );
+			expect( isAttributeAllowedInSelection( attribute, document.selection, document.schema ) ).to.be.true;
+		} );
+
+		it( 'should return false if characters with the attribute cannot be placed at caret position', () => {
+			setData( document, '<h1>[]</h1>' );
+			expect( isAttributeAllowedInSelection( attribute, document.selection, document.schema ) ).to.be.false;
+
+			setData( document, '[]' );
+			expect( isAttributeAllowedInSelection( attribute, document.selection, document.schema ) ).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( document, '<p>[foo]</p>' );
+			expect( isAttributeAllowedInSelection( attribute, document.selection, document.schema ) ).to.be.true;
+
+			// Selection spans over characters but also include nodes that can't have attribute.
+			setData( document, '<p>fo[o<img />b]ar</p>' );
+			expect( isAttributeAllowedInSelection( attribute, document.selection, document.schema ) ).to.be.true;
+
+			// Selection on whole root content. Characters in P can have an attribute so it's valid.
+			setData( document, '[<p>foo<img />bar</p><h1></h1>]' );
+			expect( isAttributeAllowedInSelection( attribute, document.selection, document.schema ) ).to.be.true;
+
+			// Selection on empty P. P can have the attribute.
+			setData( document, '[<p></p>]' );
+			expect( isAttributeAllowedInSelection( attribute, document.selection, document.schema ) ).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( document, '[<h1></h1>]' );
+			expect( isAttributeAllowedInSelection( attribute, document.selection, document.schema ) ).to.be.false;
+
+			// Selection on two images which can't be bold.
+			setData( document, '<p>foo[<img /><img />]bar</p>' );
+			expect( isAttributeAllowedInSelection( attribute, document.selection, document.schema ) ).to.be.false;
+		} );
+	} );
+} );

+ 206 - 0
packages/ckeditor5-core/tests/command/toggleattributecommand.js

@@ -0,0 +1,206 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Editor from '/ckeditor5/core/editor/editor.js';
+import Document from '/ckeditor5/engine/model/document.js';
+import ToggleAttributeCommand from '/ckeditor5/core/command/toggleattributecommand.js';
+import Range from '/ckeditor5/engine/model/range.js';
+import Position from '/ckeditor5/engine/model/position.js';
+import { setData, getData } from '/tests/engine/_utils/model.js';
+
+describe( 'ToggleAttributeCommand', () => {
+	const attrKey = 'bold';
+	let editor, command, modelDoc, root;
+
+	beforeEach( () => {
+		editor = new Editor();
+		editor.document = new Document();
+
+		modelDoc = editor.document;
+		root = modelDoc.createRoot();
+
+		command = new ToggleAttributeCommand( editor, attrKey );
+
+		modelDoc.schema.registerItem( 'p', '$block' );
+		modelDoc.schema.registerItem( 'h1', '$block' );
+		modelDoc.schema.registerItem( 'img', '$inline' );
+
+		// Allow block in "root" (DIV)
+		modelDoc.schema.allow( { name: '$block', inside: '$root' } );
+
+		// Bold text is allowed only in P.
+		modelDoc.schema.allow( { name: '$text', attributes: 'bold', inside: 'p' } );
+		modelDoc.schema.allow( { name: 'p', attributes: 'bold', inside: '$root' } );
+
+		// Disallow bold on image.
+		modelDoc.schema.disallow( { name: 'img', attributes: 'bold', inside: '$root' } );
+	} );
+
+	afterEach( () => {
+		command.destroy();
+	} );
+
+	describe( 'value', () => {
+		it( 'should be set to true or false basing on selection attribute', () => {
+			modelDoc.selection.setAttribute( attrKey, true );
+			expect( command.value ).to.be.true;
+
+			modelDoc.selection.removeAttribute( attrKey );
+			expect( command.value ).to.be.false;
+		} );
+	} );
+
+	describe( '_doExecute', () => {
+		it( 'should add attribute on selected nodes if the command value was false', () => {
+			setData( modelDoc, '<p>a[bc<$text bold="true">fo]obar</$text>xyz</p>' );
+
+			expect( command.value ).to.be.false;
+
+			command._doExecute();
+
+			expect( command.value ).to.be.true;
+			expect( getData( modelDoc ) ).to.equal( '<p>a[<$text bold="true">bcfo]obar</$text>xyz</p>' );
+		} );
+
+		it( 'should remove attribute from selected nodes if the command value was true', () => {
+			setData( modelDoc, '<p>abc[<$text bold="true">foo]bar</$text>xyz</p>' );
+
+			expect( command.value ).to.be.true;
+
+			command._doExecute();
+
+			expect( getData( modelDoc ) ).to.equal( '<p>abc[foo]<$text bold="true">bar</$text>xyz</p>' );
+			expect( command.value ).to.be.false;
+		} );
+
+		it( 'should add attribute on selected nodes if execute parameter was set to true', () => {
+			setData( modelDoc, '<p>abc<$text bold="true">foob[ar</$text>x]yz</p>' );
+
+			expect( command.value ).to.be.true;
+
+			command._doExecute( true );
+
+			expect( command.value ).to.be.true;
+			expect( getData( modelDoc ) ).to.equal( '<p>abc<$text bold="true">foob[arx</$text>]yz</p>' );
+		} );
+
+		it( 'should remove attribute on selected nodes if execute parameter was set to false', () => {
+			setData( modelDoc, '<p>a[bc<$text bold="true">fo]obar</$text>xyz</p>' );
+
+			command._doExecute( false );
+
+			expect( command.value ).to.be.false;
+			expect( getData( modelDoc ) ).to.equal( '<p>a[bcfo]<$text bold="true">obar</$text>xyz</p>' );
+		} );
+
+		it( 'should change selection attribute if selection is collapsed in non-empty parent', () => {
+			setData( modelDoc, '<p>a[]bc<$text bold="true">foobar</$text>xyz</p><p></p>' );
+
+			expect( command.value ).to.be.false;
+
+			command._doExecute();
+
+			expect( command.value ).to.be.true;
+			expect( modelDoc.selection.hasAttribute( 'bold' ) ).to.be.true;
+
+			command._doExecute();
+
+			expect( command.value ).to.be.false;
+			expect( modelDoc.selection.hasAttribute( 'bold' ) ).to.be.false;
+		} );
+
+		it( 'should not store attribute change on selection if selection is collapsed in non-empty parent', () => {
+			setData( modelDoc, '<p>a[]bc<$text bold="true">foobar</$text>xyz</p>' );
+
+			command._doExecute();
+
+			// It should not save that bold was executed at position ( root, [ 0, 1 ] ).
+
+			// Simulate clicking right arrow key by changing selection ranges.
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 2 ] ) ) ] );
+
+			// Get back to previous selection.
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 1 ] ) ) ] );
+
+			expect( command.value ).to.be.false;
+		} );
+
+		it( 'should change selection attribute and store it if selection is collapsed in empty parent', () => {
+			setData( modelDoc, '<p>abc<$text bold="true">foobar</$text>xyz</p><p>[]</p>' );
+
+			expect( command.value ).to.be.false;
+
+			command._doExecute();
+
+			expect( command.value ).to.be.true;
+			expect( modelDoc.selection.hasAttribute( 'bold' ) ).to.be.true;
+
+			// Attribute should be stored.
+			// Simulate clicking somewhere else in the editor.
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 2 ] ) ) ] );
+
+			expect( command.value ).to.be.false;
+
+			// Go back to where attribute was stored.
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 0 ] ) ) ] );
+
+			// Attribute should be restored.
+			expect( command.value ).to.be.true;
+
+			command._doExecute();
+
+			expect( command.value ).to.be.false;
+			expect( modelDoc.selection.hasAttribute( 'bold' ) ).to.be.false;
+		} );
+
+		it( 'should not apply attribute change where it would invalid schema', () => {
+			modelDoc.schema.registerItem( 'image', '$block' );
+			setData( modelDoc, '<p>ab[c<image></image><$text bold="true">foobar</$text>xy<image></image>]z</p>' );
+
+			expect( command.isEnabled ).to.be.true;
+
+			command._doExecute();
+
+			expect( getData( modelDoc ) )
+				.to.equal( '<p>ab[<$text bold="true">c</$text><image></image><$text bold="true">foobarxy</$text><image></image>]z</p>' );
+		} );
+	} );
+
+	describe( '_checkEnabled', () => {
+		describe( '_checkEnabled', () => {
+			// This test doesn't tests every possible case.
+			// Method `_checkEnabled` uses `isAttributeAllowedInSelection` helper which is fully tested in his own test.
+
+			beforeEach( () => {
+				modelDoc.schema.registerItem( 'x', '$block' );
+				modelDoc.schema.disallow( { name: '$text', inside: 'x', attributes: 'link' } );
+			} );
+
+			describe( 'when selection is collapsed', () => {
+				it( 'should return true if characters with the attribute can be placed at caret position', () => {
+					setData( modelDoc, '<p>f[]oo</p>' );
+					expect( command._checkEnabled() ).to.be.true;
+				} );
+
+				it( 'should return false if characters with the attribute cannot be placed at caret position', () => {
+					setData( modelDoc, '<x>fo[]o</x>' );
+					expect( command._checkEnabled() ).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', () => {
+					setData( modelDoc, '<p>[foo]</p>' );
+					expect( command._checkEnabled() ).to.be.true;
+				} );
+
+				it( 'should return false if there are no nodes in selection that can have the attribute', () => {
+					setData( modelDoc, '<x>[foo]</x>' );
+					expect( command._checkEnabled() ).to.be.false;
+				} );
+			} );
+		} );
+	} );
+} );