瀏覽代碼

Coe refactoring. Selected execption highlighting tests.

Aleksander Nowodzinski 6 年之前
父節點
當前提交
67ec62594f

+ 2 - 110
packages/ckeditor5-restricted-editing/src/restrictedediting.js

@@ -8,13 +8,11 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import Matcher from '@ckeditor/ckeditor5-engine/src/view/matcher';
+import RestrictedEditingEditing from './restrictededitingediting';
 import RestrictedEditingUI from './restrictededitingui';
 
 import '../theme/restrictedediting.css';
 
-const HIGHLIGHT_CLASS = 'ck-restricted-editing-exception_selected';
-
 /**
  * @extends module:core/plugin~Plugin
  */
@@ -27,112 +25,6 @@ export default class RestrictedEditing extends Plugin {
 	}
 
 	static get requires() {
-		return [ RestrictedEditingUI ];
-	}
-
-	/**
-	 * @inheritDoc
-	 */
-	init() {
-		const editor = this.editor;
-
-		let createdMarkers = 0;
-
-		editor.conversion.for( 'upcast' ).add( upcastHighlightToMarker( {
-			view: {
-				name: 'span',
-				classes: 'ck-restricted-editing-exception'
-			},
-			model: () => {
-				createdMarkers++;
-
-				return `restricted-editing-exception:${ createdMarkers }`;
-			}
-		} ) );
-
-		editor.conversion.for( 'downcast' ).markerToHighlight( {
-			model: 'restricted-editing-exception',
-			// Use callback to return new object every time new marker instance is created - otherwise it will be seen as the same marker.
-			view: () => ( {
-				name: 'span',
-				classes: 'ck-restricted-editing-exception',
-				priority: -10
-			} )
-		} );
-
-		this._setupExceptionHighlighting();
+		return [ RestrictedEditingEditing, RestrictedEditingUI ];
 	}
-
-	_setupExceptionHighlighting() {
-		const editor = this.editor;
-		const view = editor.editing.view;
-		const model = editor.model;
-		const highlightedMarkers = new Set();
-
-		// Adding the class.
-		view.document.registerPostFixer( writer => {
-			const modelSelection = model.document.selection;
-
-			for ( const marker of model.markers.getMarkersAtPosition( modelSelection.anchor ) ) {
-				for ( const viewElement of editor.editing.mapper.markerNameToElements( marker.name ) ) {
-					writer.addClass( HIGHLIGHT_CLASS, viewElement );
-					highlightedMarkers.add( viewElement );
-				}
-			}
-		} );
-
-		// Removing the class.
-		editor.conversion.for( 'editingDowncast' ).add( dispatcher => {
-			// Make sure the highlight is removed on every possible event, before conversion is started.
-			dispatcher.on( 'insert', removeHighlight, { priority: 'highest' } );
-			dispatcher.on( 'remove', removeHighlight, { priority: 'highest' } );
-			dispatcher.on( 'attribute', removeHighlight, { priority: 'highest' } );
-			dispatcher.on( 'selection', removeHighlight, { priority: 'highest' } );
-
-			function removeHighlight() {
-				view.change( writer => {
-					for ( const item of highlightedMarkers.values() ) {
-						writer.removeClass( HIGHLIGHT_CLASS, item );
-						highlightedMarkers.delete( item );
-					}
-				} );
-			}
-		} );
-	}
-}
-
-function upcastHighlightToMarker( config ) {
-	return dispatcher => dispatcher.on( 'element:span', ( evt, data, conversionApi ) => {
-		const { writer } = conversionApi;
-
-		const matcher = new Matcher( config.view );
-		const matcherResult = matcher.match( data.viewItem );
-
-		// If there is no match, this callback should not do anything.
-		if ( !matcherResult ) {
-			return;
-		}
-
-		const match = matcherResult.match;
-
-		// Force consuming element's name (taken from upcast helpers elementToElement converter).
-		match.name = true;
-
-		const { modelRange: convertedChildrenRange } = conversionApi.convertChildren( data.viewItem, data.modelCursor );
-		conversionApi.consumable.consume( data.viewItem, match );
-
-		const markerName = config.model( data.viewItem );
-		const fakeMarkerStart = writer.createElement( '$marker', { 'data-name': markerName } );
-		const fakeMarkerEnd = writer.createElement( '$marker', { 'data-name': markerName } );
-
-		// Insert in reverse order to use converter content positions directly (without recalculating).
-		writer.insert( fakeMarkerEnd, convertedChildrenRange.end );
-		writer.insert( fakeMarkerStart, convertedChildrenRange.start );
-
-		data.modelRange = writer.createRange(
-			writer.createPositionBefore( fakeMarkerStart ),
-			writer.createPositionAfter( fakeMarkerEnd )
-		);
-		data.modelCursor = data.modelRange.end;
-	} );
 }

+ 145 - 0
packages/ckeditor5-restricted-editing/src/restrictededitingediting.js

@@ -0,0 +1,145 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module restricted-editing/restrictedediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Matcher from '@ckeditor/ckeditor5-engine/src/view/matcher';
+
+const HIGHLIGHT_CLASS = 'ck-restricted-editing-exception_selected';
+
+/**
+ * @extends module:core/plugin~Plugin
+ */
+export default class RestrictedEditingEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'RestrictedEditingEditing';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+
+		let createdMarkers = 0;
+
+		editor.conversion.for( 'upcast' ).add( upcastHighlightToMarker( {
+			view: {
+				name: 'span',
+				classes: 'ck-restricted-editing-exception'
+			},
+			model: () => {
+				createdMarkers++;
+
+				return `restricted-editing-exception:${ createdMarkers }`;
+			}
+		} ) );
+
+		editor.conversion.for( 'downcast' ).markerToHighlight( {
+			model: 'restricted-editing-exception',
+			// Use callback to return new object every time new marker instance is created - otherwise it will be seen as the same marker.
+			view: () => ( {
+				name: 'span',
+				classes: 'ck-restricted-editing-exception',
+				priority: -10
+			} )
+		} );
+
+		this._setupExceptionHighlighting();
+	}
+
+	/**
+	 * Adds a visual highlight style to a restricted editing exception the selection is anchored to.
+	 *
+	 * Highlight is turned on by adding the `.ck-restricted-editing-exception_selected` class to the
+	 * exception in the view:
+	 *
+	 * * The class is removed before the conversion has started, as callbacks added with the `'highest'` priority
+	 * to {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} events.
+	 * * The class is added in the view post fixer, after other changes in the model tree were converted to the view.
+	 *
+	 * This way, adding and removing the highlight does not interfere with conversion.
+	 *
+	 * @private
+	 */
+	_setupExceptionHighlighting() {
+		const editor = this.editor;
+		const view = editor.editing.view;
+		const model = editor.model;
+		const highlightedMarkers = new Set();
+
+		// Adding the class.
+		view.document.registerPostFixer( writer => {
+			const modelSelection = model.document.selection;
+
+			for ( const marker of model.markers.getMarkersAtPosition( modelSelection.anchor ) ) {
+				for ( const viewElement of editor.editing.mapper.markerNameToElements( marker.name ) ) {
+					writer.addClass( HIGHLIGHT_CLASS, viewElement );
+					highlightedMarkers.add( viewElement );
+				}
+			}
+		} );
+
+		// Removing the class.
+		editor.conversion.for( 'editingDowncast' ).add( dispatcher => {
+			// Make sure the highlight is removed on every possible event, before conversion is started.
+			dispatcher.on( 'insert', removeHighlight, { priority: 'highest' } );
+			dispatcher.on( 'remove', removeHighlight, { priority: 'highest' } );
+			dispatcher.on( 'attribute', removeHighlight, { priority: 'highest' } );
+			dispatcher.on( 'selection', removeHighlight, { priority: 'highest' } );
+
+			function removeHighlight() {
+				view.change( writer => {
+					for ( const item of highlightedMarkers.values() ) {
+						writer.removeClass( HIGHLIGHT_CLASS, item );
+						highlightedMarkers.delete( item );
+					}
+				} );
+			}
+		} );
+	}
+}
+
+function upcastHighlightToMarker( config ) {
+	return dispatcher => dispatcher.on( 'element:span', ( evt, data, conversionApi ) => {
+		const { writer } = conversionApi;
+
+		const matcher = new Matcher( config.view );
+		const matcherResult = matcher.match( data.viewItem );
+
+		// If there is no match, this callback should not do anything.
+		if ( !matcherResult ) {
+			return;
+		}
+
+		const match = matcherResult.match;
+
+		// Force consuming element's name (taken from upcast helpers elementToElement converter).
+		match.name = true;
+
+		const { modelRange: convertedChildrenRange } = conversionApi.convertChildren( data.viewItem, data.modelCursor );
+		conversionApi.consumable.consume( data.viewItem, match );
+
+		const markerName = config.model( data.viewItem );
+		const fakeMarkerStart = writer.createElement( '$marker', { 'data-name': markerName } );
+		const fakeMarkerEnd = writer.createElement( '$marker', { 'data-name': markerName } );
+
+		// Insert in reverse order to use converter content positions directly (without recalculating).
+		writer.insert( fakeMarkerEnd, convertedChildrenRange.end );
+		writer.insert( fakeMarkerStart, convertedChildrenRange.start );
+
+		data.modelRange = writer.createRange(
+			writer.createPositionBefore( fakeMarkerStart ),
+			writer.createPositionAfter( fakeMarkerEnd )
+		);
+		data.modelCursor = data.modelRange.end;
+	} );
+}

+ 7 - 0
packages/ckeditor5-restricted-editing/src/restrictededitingui.js

@@ -20,6 +20,13 @@ export default class RestrictedEditingUI extends Plugin {
 	/**
 	 * @inheritDoc
 	 */
+	static get pluginName() {
+		return 'RestrictedEditingUI';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
 	init() {
 		const editor = this.editor;
 		const t = editor.t;

+ 17 - 153
packages/ckeditor5-restricted-editing/tests/restrictedediting.js

@@ -9,172 +9,36 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 
 import RestrictedEditing from './../src/restrictedediting';
-import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
-import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
-import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import RestrictedEditingUI from './../src/restrictededitingui';
+import RestrictedEditingEditing from './../src/restrictededitingediting';
 
 describe( 'RestrictedEditing', () => {
 	let editor, element;
 
 	testUtils.createSinonSandbox();
 
-	describe( 'plugin', () => {
-		beforeEach( async () => {
-			element = document.createElement( 'div' );
-			document.body.appendChild( element );
+	beforeEach( async () => {
+		element = document.createElement( 'div' );
+		document.body.appendChild( element );
 
-			editor = await ClassicTestEditor.create( element, { plugins: [ RestrictedEditing ] } );
-		} );
-
-		afterEach( () => {
-			element.remove();
-
-			return editor.destroy();
-		} );
-
-		it( 'should be named', () => {
-			expect( RestrictedEditing.pluginName ).to.equal( 'RestrictedEditing' );
-		} );
-
-		it( 'should be loaded', () => {
-			expect( editor.plugins.get( RestrictedEditing ) ).to.be.instanceOf( RestrictedEditing );
-		} );
+		editor = await ClassicTestEditor.create( element, { plugins: [ RestrictedEditing ] } );
 	} );
 
-	describe( 'conversion', () => {
-		let model;
-
-		beforeEach( async () => {
-			editor = await VirtualTestEditor.create( { plugins: [ Paragraph, RestrictedEditing ] } );
-			model = editor.model;
-		} );
-
-		afterEach( () => {
-			return editor.destroy();
-		} );
-
-		describe( 'upcast', () => {
-			it( 'should convert <span class="ck-restricted-editing-exception"> to marker', () => {
-				editor.setData( '<p>foo <span class="ck-restricted-editing-exception">bar</span> baz</p>' );
-
-				expect( model.markers.has( 'restricted-editing-exception:1' ) ).to.be.true;
-
-				const marker = model.markers.get( 'restricted-editing-exception:1' );
-
-				expect( marker.getStart().path ).to.deep.equal( [ 0, 4 ] );
-				expect( marker.getEnd().path ).to.deep.equal( [ 0, 7 ] );
-			} );
-
-			it( 'should convert multiple <span class="ck-restricted-editing-exception">', () => {
-				editor.setData(
-					'<p>foo <span class="ck-restricted-editing-exception">bar</span> baz</p>' +
-					'<p>ABCDEF<span class="ck-restricted-editing-exception">GHIJK</span>LMNOPQRST</p>'
-				);
-
-				expect( model.markers.has( 'restricted-editing-exception:1' ) ).to.be.true;
-				expect( model.markers.has( 'restricted-editing-exception:2' ) ).to.be.true;
-
-				// Data for the first marker is the same as in previous tests so no need to test it again.
-				const secondMarker = model.markers.get( 'restricted-editing-exception:2' );
-
-				expect( secondMarker.getStart().path ).to.deep.equal( [ 1, 6 ] );
-				expect( secondMarker.getEnd().path ).to.deep.equal( [ 1, 11 ] );
-			} );
-
-			it( 'should not convert other <span> elements', () => {
-				editor.setData( '<p>foo <span class="foo bar">bar</span> baz</p>' );
-
-				expect( model.markers.has( 'restricted-editing-exception:1' ) ).to.be.false;
-			} );
-		} );
+	afterEach( () => {
+		element.remove();
 
-		describe( 'downcast', () => {
-			it( 'should convert model marker to <span>', () => {
-				setModelData( model, '<paragraph>foo bar baz</paragraph>' );
-
-				const paragraph = model.document.getRoot().getChild( 0 );
-
-				model.change( writer => {
-					writer.addMarker( 'restricted-editing-exception:1', {
-						range: writer.createRange( writer.createPositionAt( paragraph, 4 ), writer.createPositionAt( paragraph, 7 ) ),
-						usingOperation: true,
-						affectsData: true
-					} );
-				} );
-
-				const expectedView = '<p>foo <span class="ck-restricted-editing-exception">bar</span> baz</p>';
-				expect( editor.getData() ).to.equal( expectedView );
-				expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
-			} );
-
-			it( 'converted <span> should be the outermost attribute element', () => {
-				editor.conversion.for( 'downcast' ).attributeToElement( { model: 'bold', view: 'b' } );
-				setModelData( model, '<paragraph><$text bold="true">foo bar baz</$text></paragraph>' );
-
-				const paragraph = model.document.getRoot().getChild( 0 );
-
-				model.change( writer => {
-					writer.addMarker( 'restricted-editing-exception:1', {
-						range: writer.createRange( writer.createPositionAt( paragraph, 0 ), writer.createPositionAt( paragraph, 'end' ) ),
-						usingOperation: true,
-						affectsData: true
-					} );
-				} );
-
-				const expectedView = '<p><span class="ck-restricted-editing-exception"><b>foo bar baz</b></span></p>';
-				expect( editor.getData() ).to.equal( expectedView );
-				expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
-			} );
-		} );
+		return editor.destroy();
 	} );
 
-	describe( 'editing behavior', () => {
-		let model;
-
-		beforeEach( async () => {
-			editor = await VirtualTestEditor.create( { plugins: [ Paragraph, RestrictedEditing ] } );
-			model = editor.model;
-		} );
-
-		afterEach( () => {
-			return editor.destroy();
-		} );
-
-		it( 'should keep markers in the view when editable region is edited', () => {
-			setModelData( model,
-				'<paragraph>foo bar baz</paragraph>' +
-				'<paragraph>xxx y[]yy zzz</paragraph>'
-			);
-
-			const firstParagraph = model.document.getRoot().getChild( 0 );
-			const secondParagraph = model.document.getRoot().getChild( 1 );
-
-			model.change( writer => {
-				writer.addMarker( 'restricted-editing-exception:1', {
-					range: writer.createRange( writer.createPositionAt( firstParagraph, 4 ), writer.createPositionAt( firstParagraph, 7 ) ),
-					usingOperation: true,
-					affectsData: true
-				} );
-				writer.addMarker( 'restricted-editing-exception:2', {
-					range: writer.createRange(
-						writer.createPositionAt( secondParagraph, 4 ),
-						writer.createPositionAt( secondParagraph, 7 )
-					),
-					usingOperation: true,
-					affectsData: true
-				} );
-			} );
-
-			model.change( writer => {
-				model.insertContent( writer.createText( 'R', model.document.selection.getAttributes() ) );
-			} );
+	it( 'should be named', () => {
+		expect( RestrictedEditing.pluginName ).to.equal( 'RestrictedEditing' );
+	} );
 
-			const expectedView = '<p>foo <span class="ck-restricted-editing-exception">bar</span> baz</p>' +
-				'<p>xxx <span class="ck-restricted-editing-exception">yRyy</span> zzz</p>';
+	it( 'should load the RestrictedEditingEditing plugin', () => {
+		expect( editor.plugins.get( RestrictedEditingEditing ) ).to.be.instanceOf( RestrictedEditingEditing );
+	} );
 
-			expect( editor.getData() ).to.equal( expectedView );
-			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
-		} );
+	it( 'should load the RestrictedEditingUI plugin', () => {
+		expect( editor.plugins.get( RestrictedEditingUI ) ).to.be.instanceOf( RestrictedEditingUI );
 	} );
 } );

+ 416 - 0
packages/ckeditor5-restricted-editing/tests/restrictededitingediting.js

@@ -0,0 +1,416 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global document */
+
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+
+import RestrictedEditingEditing from './../src/restrictededitingediting';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting';
+
+describe( 'RestrictedEditingEditing', () => {
+	let editor, element;
+
+	testUtils.createSinonSandbox();
+
+	describe( 'plugin', () => {
+		beforeEach( async () => {
+			element = document.createElement( 'div' );
+			document.body.appendChild( element );
+
+			editor = await ClassicTestEditor.create( element, { plugins: [ RestrictedEditingEditing ] } );
+		} );
+
+		afterEach( () => {
+			element.remove();
+
+			return editor.destroy();
+		} );
+
+		it( 'should be named', () => {
+			expect( RestrictedEditingEditing.pluginName ).to.equal( 'RestrictedEditingEditing' );
+		} );
+
+		it( 'should be loaded', () => {
+			expect( editor.plugins.get( RestrictedEditingEditing ) ).to.be.instanceOf( RestrictedEditingEditing );
+		} );
+	} );
+
+	describe( 'conversion', () => {
+		let model;
+
+		beforeEach( async () => {
+			editor = await VirtualTestEditor.create( { plugins: [ Paragraph, RestrictedEditingEditing ] } );
+			model = editor.model;
+		} );
+
+		afterEach( () => {
+			return editor.destroy();
+		} );
+
+		describe( 'upcast', () => {
+			it( 'should convert <span class="ck-restricted-editing-exception"> to marker', () => {
+				editor.setData( '<p>foo <span class="ck-restricted-editing-exception">bar</span> baz</p>' );
+
+				expect( model.markers.has( 'restricted-editing-exception:1' ) ).to.be.true;
+
+				const marker = model.markers.get( 'restricted-editing-exception:1' );
+
+				expect( marker.getStart().path ).to.deep.equal( [ 0, 4 ] );
+				expect( marker.getEnd().path ).to.deep.equal( [ 0, 7 ] );
+			} );
+
+			it( 'should convert multiple <span class="ck-restricted-editing-exception">', () => {
+				editor.setData(
+					'<p>foo <span class="ck-restricted-editing-exception">bar</span> baz</p>' +
+					'<p>ABCDEF<span class="ck-restricted-editing-exception">GHIJK</span>LMNOPQRST</p>'
+				);
+
+				expect( model.markers.has( 'restricted-editing-exception:1' ) ).to.be.true;
+				expect( model.markers.has( 'restricted-editing-exception:2' ) ).to.be.true;
+
+				// Data for the first marker is the same as in previous tests so no need to test it again.
+				const secondMarker = model.markers.get( 'restricted-editing-exception:2' );
+
+				expect( secondMarker.getStart().path ).to.deep.equal( [ 1, 6 ] );
+				expect( secondMarker.getEnd().path ).to.deep.equal( [ 1, 11 ] );
+			} );
+
+			it( 'should not convert other <span> elements', () => {
+				editor.setData( '<p>foo <span class="foo bar">bar</span> baz</p>' );
+
+				expect( model.markers.has( 'restricted-editing-exception:1' ) ).to.be.false;
+			} );
+		} );
+
+		describe( 'downcast', () => {
+			it( 'should convert model marker to <span>', () => {
+				setModelData( model, '<paragraph>foo bar baz</paragraph>' );
+
+				const paragraph = model.document.getRoot().getChild( 0 );
+
+				model.change( writer => {
+					writer.addMarker( 'restricted-editing-exception:1', {
+						range: writer.createRange( writer.createPositionAt( paragraph, 4 ), writer.createPositionAt( paragraph, 7 ) ),
+						usingOperation: true,
+						affectsData: true
+					} );
+				} );
+
+				const expectedView = '<p>foo <span class="ck-restricted-editing-exception">bar</span> baz</p>';
+				expect( editor.getData() ).to.equal( expectedView );
+				expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
+			} );
+
+			it( 'converted <span> should be the outermost attribute element', () => {
+				editor.conversion.for( 'downcast' ).attributeToElement( { model: 'bold', view: 'b' } );
+				setModelData( model, '<paragraph><$text bold="true">foo bar baz</$text></paragraph>' );
+
+				const paragraph = model.document.getRoot().getChild( 0 );
+
+				model.change( writer => {
+					writer.addMarker( 'restricted-editing-exception:1', {
+						range: writer.createRange( writer.createPositionAt( paragraph, 0 ), writer.createPositionAt( paragraph, 'end' ) ),
+						usingOperation: true,
+						affectsData: true
+					} );
+				} );
+
+				const expectedView = '<p><span class="ck-restricted-editing-exception"><b>foo bar baz</b></span></p>';
+				expect( editor.getData() ).to.equal( expectedView );
+				expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView );
+			} );
+		} );
+	} );
+
+	describe( 'editing behavior', () => {
+		let model;
+
+		beforeEach( async () => {
+			editor = await VirtualTestEditor.create( { plugins: [ Paragraph, RestrictedEditingEditing ] } );
+			model = editor.model;
+		} );
+
+		afterEach( () => {
+			return editor.destroy();
+		} );
+
+		it( 'should keep markers in the view when editable region is edited', () => {
+			setModelData( model,
+				'<paragraph>foo bar baz</paragraph>' +
+				'<paragraph>xxx y[]yy zzz</paragraph>'
+			);
+
+			const firstParagraph = model.document.getRoot().getChild( 0 );
+			const secondParagraph = model.document.getRoot().getChild( 1 );
+
+			model.change( writer => {
+				writer.addMarker( 'restricted-editing-exception:1', {
+					range: writer.createRange( writer.createPositionAt( firstParagraph, 4 ), writer.createPositionAt( firstParagraph, 7 ) ),
+					usingOperation: true,
+					affectsData: true
+				} );
+				writer.addMarker( 'restricted-editing-exception:2', {
+					range: writer.createRange(
+						writer.createPositionAt( secondParagraph, 4 ),
+						writer.createPositionAt( secondParagraph, 7 )
+					),
+					usingOperation: true,
+					affectsData: true
+				} );
+			} );
+
+			model.change( writer => {
+				model.insertContent( writer.createText( 'R', model.document.selection.getAttributes() ) );
+			} );
+
+			expect( editor.getData() ).to.equal(
+				'<p>foo <span class="ck-restricted-editing-exception">bar</span> baz</p>' +
+				'<p>xxx <span class="ck-restricted-editing-exception">yRyy</span> zzz</p>' );
+
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal(
+				'<p>foo <span class="ck-restricted-editing-exception">bar</span> baz</p>' +
+				'<p>xxx <span class="ck-restricted-editing-exception ck-restricted-editing-exception_selected">yRyy</span> zzz</p>' );
+		} );
+	} );
+
+	describe( 'exception highlighting', () => {
+		let model, view;
+
+		beforeEach( async () => {
+			editor = await VirtualTestEditor.create( {
+				plugins: [ Paragraph, RestrictedEditingEditing, BoldEditing ]
+			} );
+			model = editor.model;
+			view = editor.editing.view;
+		} );
+
+		afterEach( () => {
+			return editor.destroy();
+		} );
+
+		it( 'should convert the highlight to a proper view classes', () => {
+			setModelData( model, '<paragraph>foo b[a]r baz</paragraph>' );
+
+			const paragraph = model.document.getRoot().getChild( 0 );
+
+			// <paragraph>foo <$marker>b[a]r</$marker> baz</paragraph>
+			model.change( writer => {
+				writer.addMarker( 'restricted-editing-exception:1', {
+					range: writer.createRange( writer.createPositionAt( paragraph, 4 ), writer.createPositionAt( paragraph, 7 ) ),
+					usingOperation: true,
+					affectsData: true
+				} );
+			} );
+
+			expect( getViewData( view ) ).to.equal(
+				'<p>foo <span class="ck-restricted-editing-exception ck-restricted-editing-exception_selected">b{a}r</span> baz</p>'
+			);
+		} );
+
+		it( 'should remove classes when selection is moved away from an exception', () => {
+			setModelData( model, '<paragraph>foo b[a]r baz</paragraph>' );
+
+			const paragraph = model.document.getRoot().getChild( 0 );
+
+			// <paragraph>foo <$marker>b[a]r</$marker> baz</paragraph>
+			model.change( writer => {
+				writer.addMarker( 'restricted-editing-exception:1', {
+					range: writer.createRange( writer.createPositionAt( paragraph, 4 ), writer.createPositionAt( paragraph, 7 ) ),
+					usingOperation: true,
+					affectsData: true
+				} );
+			} );
+
+			expect( getViewData( view ) ).to.equal(
+				'<p>foo <span class="ck-restricted-editing-exception ck-restricted-editing-exception_selected">b{a}r</span> baz</p>'
+			);
+
+			model.change( writer => writer.setSelection( model.document.getRoot().getChild( 0 ), 0 ) );
+
+			expect( getViewData( view ) ).to.equal(
+				'<p>{}foo <span class="ck-restricted-editing-exception">bar</span> baz</p>'
+			);
+		} );
+
+		it( 'should work correctly when selection is moved inside an exception', () => {
+			setModelData( model, '<paragraph>[]foo bar baz</paragraph>' );
+
+			const paragraph = model.document.getRoot().getChild( 0 );
+
+			// <paragraph>[]foo <$marker>bar</$marker> baz</paragraph>
+			model.change( writer => {
+				writer.addMarker( 'restricted-editing-exception:1', {
+					range: writer.createRange( writer.createPositionAt( paragraph, 4 ), writer.createPositionAt( paragraph, 7 ) ),
+					usingOperation: true,
+					affectsData: true
+				} );
+			} );
+
+			expect( getViewData( view ) ).to.equal(
+				'<p>{}foo <span class="ck-restricted-editing-exception">bar</span> baz</p>'
+			);
+
+			model.change( writer => writer.setSelection( model.document.getRoot().getChild( 0 ), 6 ) );
+
+			expect( getViewData( view ) ).to.equal(
+				'<p>foo <span class="ck-restricted-editing-exception ck-restricted-editing-exception_selected">ba{}r</span> baz</p>'
+			);
+		} );
+
+		describe( 'editing downcast conversion integration', () => {
+			it( 'works for the #insert event', () => {
+				setModelData( model, '<paragraph>foo b[a]r baz</paragraph>' );
+
+				const paragraph = model.document.getRoot().getChild( 0 );
+
+				// <paragraph>foo <$marker>b[a]r</$marker> baz</paragraph>
+				model.change( writer => {
+					writer.addMarker( 'restricted-editing-exception:1', {
+						range: writer.createRange( writer.createPositionAt( paragraph, 4 ), writer.createPositionAt( paragraph, 7 ) ),
+						usingOperation: true,
+						affectsData: true
+					} );
+				} );
+
+				model.change( writer => {
+					writer.insertText( 'FOO', { linkHref: 'url' }, model.document.selection.getFirstPosition() );
+				} );
+
+				expect( getViewData( view ) ).to.equal(
+					'<p>foo <span class="ck-restricted-editing-exception ck-restricted-editing-exception_selected">bFOO{a}r</span> baz</p>'
+				);
+			} );
+
+			it( 'works for the #remove event', () => {
+				setModelData( model, '<paragraph>foo b[a]r baz</paragraph>' );
+
+				const paragraph = model.document.getRoot().getChild( 0 );
+
+				// <paragraph>foo <$marker>b[a]r</$marker> baz</paragraph>
+				model.change( writer => {
+					writer.addMarker( 'restricted-editing-exception:1', {
+						range: writer.createRange( writer.createPositionAt( paragraph, 4 ), writer.createPositionAt( paragraph, 7 ) ),
+						usingOperation: true,
+						affectsData: true
+					} );
+				} );
+
+				model.change( writer => {
+					writer.remove( writer.createRange(
+						writer.createPositionAt( model.document.getRoot().getChild( 0 ), 5 ),
+						writer.createPositionAt( model.document.getRoot().getChild( 0 ), 6 )
+					) );
+				} );
+
+				expect( getViewData( view ) ).to.equal(
+					'<p>foo <span class="ck-restricted-editing-exception ck-restricted-editing-exception_selected">b{}r</span> baz</p>'
+				);
+			} );
+
+			it( 'works for the #attribute event', () => {
+				setModelData( model, '<paragraph>foo b[a]r baz</paragraph>' );
+
+				const paragraph = model.document.getRoot().getChild( 0 );
+
+				// <paragraph>foo <$marker>b[a]r</$marker> baz</paragraph>
+				model.change( writer => {
+					writer.addMarker( 'restricted-editing-exception:1', {
+						range: writer.createRange( writer.createPositionAt( paragraph, 4 ), writer.createPositionAt( paragraph, 7 ) ),
+						usingOperation: true,
+						affectsData: true
+					} );
+				} );
+
+				model.change( writer => {
+					writer.setAttribute( 'bold', true, writer.createRange(
+						model.document.selection.getFirstPosition().getShiftedBy( -1 ),
+						model.document.selection.getFirstPosition().getShiftedBy( 1 ) )
+					);
+				} );
+
+				expect( getViewData( view ) ).to.equal(
+					'<p>foo ' +
+						'<span class="ck-restricted-editing-exception ck-restricted-editing-exception_selected">' +
+							'<strong>b{a</strong>' +
+						'}r</span>' +
+					' baz</p>'
+				);
+			} );
+
+			it( 'works for the #selection event', () => {
+				setModelData( model, '<paragraph>foo b[a]r baz</paragraph>' );
+
+				const paragraph = model.document.getRoot().getChild( 0 );
+
+				// <paragraph>foo <$marker>b[a]r</$marker> baz</paragraph>
+				model.change( writer => {
+					writer.addMarker( 'restricted-editing-exception:1', {
+						range: writer.createRange( writer.createPositionAt( paragraph, 4 ), writer.createPositionAt( paragraph, 7 ) ),
+						usingOperation: true,
+						affectsData: true
+					} );
+				} );
+
+				model.change( writer => {
+					writer.setSelection( writer.createRange(
+						model.document.selection.getFirstPosition().getShiftedBy( -1 ),
+						model.document.selection.getFirstPosition().getShiftedBy( 1 ) )
+					);
+				} );
+
+				expect( getViewData( view ) ).to.equal(
+					'<p>foo {<span class="ck-restricted-editing-exception">ba}r</span> baz</p>'
+				);
+			} );
+
+			it( 'works for the addMarker and removeMarker events', () => {
+				editor.conversion.for( 'editingDowncast' ).markerToHighlight( { model: 'fooMarker', view: {} } );
+
+				setModelData( model, '<paragraph>foo b[a]r baz</paragraph>' );
+
+				const paragraph = model.document.getRoot().getChild( 0 );
+
+				// <paragraph>foo <$marker>b[a]r</$marker> baz</paragraph>
+				model.change( writer => {
+					writer.addMarker( 'restricted-editing-exception:1', {
+						range: writer.createRange( writer.createPositionAt( paragraph, 4 ), writer.createPositionAt( paragraph, 7 ) ),
+						usingOperation: true,
+						affectsData: true
+					} );
+				} );
+
+				model.change( writer => {
+					const range = writer.createRange(
+						writer.createPositionAt( model.document.getRoot().getChild( 0 ), 0 ),
+						writer.createPositionAt( model.document.getRoot().getChild( 0 ), 5 )
+					);
+
+					writer.addMarker( 'fooMarker', { range, usingOperation: true } );
+				} );
+
+				expect( getViewData( view ) ).to.equal(
+					'<p>' +
+						'<span>foo </span>' +
+						'<span class="ck-restricted-editing-exception ck-restricted-editing-exception_selected">' +
+							'<span>b</span>{a}r' +
+						'</span>' +
+					' baz</p>'
+				);
+
+				model.change( writer => writer.removeMarker( 'fooMarker' ) );
+
+				expect( getViewData( view ) ).to.equal(
+					'<p>foo <span class="ck-restricted-editing-exception ck-restricted-editing-exception_selected">b{a}r</span> baz</p>'
+				);
+			} );
+		} );
+	} );
+} );