Przeglądaj źródła

Merge branch 'master' into i/5762

# Conflicts:
#	src/restrictedediting.js
#	src/restrictededitingediting.js
#	tests/restrictedediting.js
#	tests/restrictededitingediting.js
Maciej Gołaszewski 6 lat temu
rodzic
commit
0c9359f3a5

+ 7 - 0
packages/ckeditor5-restricted-editing/lang/contexts.json

@@ -0,0 +1,7 @@
+{
+	"Disable editing": "A label of the button indicating that using this button will make a selected text non–editable.",
+	"Enable editing": "A label of the button indicating that using this button will make a selected text editable.",
+	"Previous editable region": "A label of the button that moves selection to the previous editable region in the content.",
+	"Next editable region": "A label of the button that moves selection to the next editable region in the content.",
+	"Navigate editable regions": "A label of the dropdown that provides controls to navigate editable regions in the content."
+}

+ 1 - 0
packages/ckeditor5-restricted-editing/package.json

@@ -13,6 +13,7 @@
     "@ckeditor/ckeditor5-ui": "^15.0.0"
   },
   "devDependencies": {
+    "@ckeditor/ckeditor5-basic-styles": "^15.0.0",
     "@ckeditor/ckeditor5-editor-classic": "^15.0.0",
     "@ckeditor/ckeditor5-engine": "^15.0.0",
     "@ckeditor/ckeditor5-paragraph": "^15.0.0",

+ 4 - 1
packages/ckeditor5-restricted-editing/src/restrictedediting.js

@@ -9,6 +9,9 @@
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import RestrictedEditingEditing from './restrictededitingediting';
+import RestrictedEditingUI from './restrictededitingui';
+
+import '../theme/restrictedediting.css';
 
 /**
  * @extends module:core/plugin~Plugin
@@ -22,6 +25,6 @@ export default class RestrictedEditing extends Plugin {
 	}
 
 	static get requires() {
-		return [ RestrictedEditingEditing ];
+		return [ RestrictedEditingEditing, RestrictedEditingUI ];
 	}
 }

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

@@ -9,8 +9,17 @@
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Matcher from '@ckeditor/ckeditor5-engine/src/view/matcher';
+import RestrictedEditingNavigationCommand from './restrictededitingnavigationcommand';
+
+const HIGHLIGHT_CLASS = 'ck-restricted-editing-exception_selected';
 
 /**
+ * The Restricted Editing editing feature.
+ *
+ * * It introduces the exception marker group that renders to `<spans>` with the `ck-restricted-editing-exception` CSS class.
+ * * It registers the `'goToPreviousRestrictedEditingRegion'` and `'goToNextRestrictedEditingRegion'` commands.
+ * * Also enables highlighting exception markers that are selected.
+ *
  * @extends module:core/plugin~Plugin
  */
 export default class RestrictedEditingEditing extends Plugin {
@@ -37,6 +46,10 @@ export default class RestrictedEditingEditing extends Plugin {
 	init() {
 		const editor = this.editor;
 
+		// Commands that allow navigation in the content.
+		editor.commands.add( 'goToPreviousRestrictedEditingRegion', new RestrictedEditingNavigationCommand( editor, 'backward' ) );
+		editor.commands.add( 'goToNextRestrictedEditingRegion', new RestrictedEditingNavigationCommand( editor, 'forward' ) );
+
 		let createdMarkers = 0;
 
 		editor.conversion.for( 'upcast' ).add( upcastHighlightToMarker( {
@@ -61,9 +74,76 @@ export default class RestrictedEditingEditing extends Plugin {
 			} )
 		} );
 
+		const getCommandExecuter = commandName => {
+			return ( data, cancel ) => {
+				const command = this.editor.commands.get( commandName );
+
+				if ( command.isEnabled ) {
+					this.editor.execute( commandName );
+				}
+
+				cancel();
+			};
+		};
+
+		editor.keystrokes.set( 'Tab', getCommandExecuter( 'goToNextRestrictedEditingRegion' ) );
+		editor.keystrokes.set( 'Shift+Tab', getCommandExecuter( 'goToPreviousRestrictedEditingRegion' ) );
+
+		this._setupExceptionHighlighting();
 		this._setupRestrictedMode( editor );
 	}
 
+	/**
+	 * 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 );
+					}
+				} );
+			}
+		} );
+	}
+
 	_setupRestrictedMode( editor ) {
 		this._disableCommands( editor );
 

+ 2 - 0
packages/ckeditor5-restricted-editing/src/restrictededitingexception.js

@@ -12,6 +12,8 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import RestrictedEditingExceptionEditing from './restrictededitingexceptionediting';
 import RestrictedEditingExceptionUI from './restrictededitingexceptionui';
 
+import '../theme/restrictedediting.css';
+
 /**
  * @extends module:core/plugin~Plugin
  */

+ 5 - 3
packages/ckeditor5-restricted-editing/src/restrictededitingexceptionui.js

@@ -10,7 +10,7 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 
-import restrictedDocumentIcon from '../theme/icons/contentlock.svg';
+import unlockIcon from '../theme/icons/contentunlock.svg';
 
 /**
  * @extends module:core/plugin~Plugin
@@ -28,13 +28,15 @@ export default class RestrictedEditingExceptionUI extends Plugin {
 			const view = new ButtonView( locale );
 
 			view.set( {
-				label: t( 'Enable editing' ),
-				icon: restrictedDocumentIcon,
+				icon: unlockIcon,
 				tooltip: true,
 				isToggleable: true
 			} );
 
 			view.bind( 'isOn', 'isEnabled' ).to( command, 'value', 'isEnabled' );
+			view.bind( 'label' ).to( command, 'value', value => {
+				return value ? t( 'Disable editing' ) : t( 'Enable editing' );
+			} );
 
 			this.listenTo( view, 'execute', () => editor.execute( 'restrictedEditingException' ) );
 

+ 117 - 0
packages/ckeditor5-restricted-editing/src/restrictededitingnavigationcommand.js

@@ -0,0 +1,117 @@
+/**
+ * @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/restrictededitingnavigationcommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+
+/**
+ * The command that allows navigation across the exceptions in the edited document.
+ *
+ * @extends module:core/command~Command
+ */
+export default class RestrictedEditingNavigationCommand extends Command {
+	/**
+	 * Creates an instance of the command.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor Editor instance.
+	 * @param {String} direction Direction the command works. Can be either `'forward'` or `'backward'`.
+	 */
+	constructor( editor, direction ) {
+		super( editor );
+
+		/**
+		 * A direction of the command. Can be `'forward'` or `'backward'`.
+		 *
+		 * @readonly
+		 * @private
+		 * @member {String}
+		 */
+		this._direction = direction;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		this.isEnabled = this._checkEnabled();
+	}
+
+	/**
+	 * Executes the command.
+	 *
+	 * @fires execute
+	 */
+	execute() {
+		const position = getNearestExceptionRange( this.editor.model, this._direction );
+
+		this.editor.model.change( writer => {
+			writer.setSelection( position );
+		} );
+	}
+
+	/**
+	 * Checks whether the command can be enabled in the current context.
+	 *
+	 * @private
+	 * @returns {Boolean} Whether the command should be enabled.
+	 */
+	_checkEnabled() {
+		return !!getNearestExceptionRange( this.editor.model, this._direction );
+	}
+}
+
+// Returns the range of the exception marker closest to the last position of the
+// model selection.
+//
+// @param {module:engine/model/model~Model} model
+// @param {String} direction Either "forward" or "backward".
+// @returns {module:engine/model/range~Range|null}
+function getNearestExceptionRange( model, direction ) {
+	const selection = model.document.selection;
+	const selectionPosition = selection.getFirstPosition();
+	const markerRanges = [];
+
+	// Get all exception marker positions that start after/before the selection position.
+	for ( const marker of model.markers.getMarkersGroup( 'restricted-editing-exception' ) ) {
+		const markerRange = marker.getRange();
+
+		// Checking parent because there two positions <paragraph>foo^</paragraph><paragraph>^bar</paragraph>
+		// are touching but they will represent different markers.
+		const isMarkerRangeTouching =
+			selectionPosition.isTouching( markerRange.start ) && selectionPosition.hasSameParentAs( markerRange.start ) ||
+			selectionPosition.isTouching( markerRange.end ) && selectionPosition.hasSameParentAs( markerRange.end );
+
+		// <paragraph>foo <marker≥b[]ar</marker> baz</paragraph>
+		// <paragraph>foo <marker≥b[ar</marker> ba]z</paragraph>
+		// <paragraph>foo <marker≥bar</marker>[] baz</paragraph>
+		// <paragraph>foo []<marker≥bar</marker> baz</paragraph>
+		if ( markerRange.containsPosition( selectionPosition ) || isMarkerRangeTouching ) {
+			continue;
+		}
+
+		if ( direction === 'forward' && markerRange.start.isAfter( selectionPosition ) ) {
+			markerRanges.push( markerRange );
+		} else if ( direction === 'backward' && markerRange.end.isBefore( selectionPosition ) ) {
+			markerRanges.push( markerRange );
+		}
+	}
+
+	if ( !markerRanges.length ) {
+		return null;
+	}
+
+	// Get the marker closest to the selection position among many. To know that, we need to sort
+	// them first.
+	return markerRanges.sort( ( rangeA, rangeB ) => {
+		if ( direction === 'forward' ) {
+			return rangeA.start.isAfter( rangeB.start ) ? 1 : -1;
+		} else {
+			return rangeA.start.isBefore( rangeB.start ) ? 1 : -1;
+		}
+	} ).shift();
+}

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

@@ -0,0 +1,99 @@
+/**
+ * @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/restrictededitingui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import { createDropdown, addListToDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
+import Model from '@ckeditor/ckeditor5-ui/src/model';
+import lockIcon from '../theme/icons/contentlock.svg';
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
+
+/**
+ * The Restricted Editing UI feature.
+ *
+ * It introduces the `'restrictedEditing'` dropdown that offers tools to navigate exceptions across
+ * the document.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class RestrictedEditingUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'RestrictedEditingUI';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const t = editor.t;
+
+		editor.ui.componentFactory.add( 'restrictedEditing', locale => {
+			const dropdownView = createDropdown( locale );
+			const listItems = new Collection();
+
+			listItems.add( this._getButtonDefinition(
+				'goToPreviousRestrictedEditingRegion',
+				t( 'Previous editable region' ),
+				'Shift+Tab'
+			) );
+			listItems.add( this._getButtonDefinition(
+				'goToNextRestrictedEditingRegion',
+				t( 'Next editable region' ),
+				'Tab'
+			) );
+
+			addListToDropdown( dropdownView, listItems );
+
+			dropdownView.buttonView.set( {
+				label: t( 'Navigate editable regions' ),
+				icon: lockIcon,
+				tooltip: true,
+				isEnabled: true,
+				isOn: false
+			} );
+
+			this.listenTo( dropdownView, 'execute', evt => {
+				editor.execute( evt.source._commandName );
+				editor.editing.view.focus();
+			} );
+
+			return dropdownView;
+		} );
+	}
+
+	/**
+	 * Returns a definition of the navigation button to be used in the dropdown.
+	 *
+	 * @private
+	 * @param {String} commandName Name of the command the button represents.
+	 * @param {String} label Translated label of the button.
+	 * @returns {module:ui/dropdown/utils~ListDropdownItemDefinition}
+	 */
+	_getButtonDefinition( commandName, label, kestroke ) {
+		const editor = this.editor;
+		const command = editor.commands.get( commandName );
+		const definition = {
+			type: 'button',
+			model: new Model( {
+				label,
+				withText: true,
+				keystroke: kestroke,
+				withKeystroke: true,
+				_commandName: commandName
+			} )
+		};
+
+		definition.model.bind( 'isEnabled' ).to( command, 'isEnabled' );
+
+		return definition;
+	}
+}

+ 5 - 30
packages/ckeditor5-restricted-editing/tests/manual/restrictedediting.html

@@ -1,10 +1,9 @@
 <p>
-	<button id="mode-standard">Switch to standard mode</button>
-	<button id="mode-restricted">Switch to restricted mode</button>
+	<b>Mode</b>:
+	<input type="radio" id="mode-standard" name="mode" value="standard" checked><label for="mode-standard">Standard</label>
+	<input type="radio" id="mode-restricted" name="mode" value="restricted"><label for="mode-restricted">Restricted</label>
 </p>
 
-<p id="current-mode"></p>
-
 <div id="editor">
 	<h2>Heading 1</h2>
 	<p>Paragraph <span class="ck-restricted-editing-exception">it is editable</span></p>
@@ -14,7 +13,7 @@
 		<li>UL List item 2</li>
 	</ul>
 	<ol>
-		<li>OL List item 1</li>
+		<li><span class="ck-restricted-editing-exception">OL List item 1</span></li>
 		<li>OL List item 2</li>
 	</ol>
 	<figure class="image image-style-side">
@@ -25,32 +24,8 @@
 		<p>Quote</p>
 		<ul>
 			<li>Quoted UL List item 1</li>
-			<li>Quoted UL List item 2</li>
+			<li>Quoted UL List item <span class="ck-restricted-editing-exception">2</span></li>
 		</ul>
 		<p>Quote</p>
 	</blockquote>
 </div>
-
-<style>
-	.ck-restricted-editing-exception {
-		background-color: #ffcd96;
-	}
-
-	#current-mode {
-		/*text-align: center;*/
-		padding: 1em 0;
-	}
-
-	.mode {
-		color: #fff;
-		padding: 0.5em;
-	}
-
-	.mode-standard {
-		background: #4f4fff;
-	}
-
-	.mode-restricted {
-		background: #a72727;
-	}
-</style>

+ 9 - 22
packages/ckeditor5-restricted-editing/tests/manual/restrictedediting.js

@@ -14,25 +14,21 @@ import RestrictedEditing from '../../src/restrictedediting';
 
 const restrictedModeButton = document.getElementById( 'mode-restricted' );
 const standardModeButton = document.getElementById( 'mode-standard' );
-const currentModeDisplay = document.getElementById( 'current-mode' );
 
-enableSwitchToStandardMode();
-enableSwitchToRestrictedMode();
+restrictedModeButton.addEventListener( 'change', handleModeChange );
+standardModeButton.addEventListener( 'change', handleModeChange );
 
-function enableSwitchToRestrictedMode() {
-	restrictedModeButton.removeAttribute( 'disabled' );
-	restrictedModeButton.addEventListener( 'click', startRestrictedMode );
-}
+startStandardMode();
 
-function enableSwitchToStandardMode() {
-	standardModeButton.removeAttribute( 'disabled' );
-	standardModeButton.addEventListener( 'click', startStandardMode );
+function handleModeChange( evt ) {
+	if ( evt.target.value === 'standard' ) {
+		startStandardMode();
+	} else {
+		startRestrictedMode();
+	}
 }
 
 async function startStandardMode() {
-	standardModeButton.removeEventListener( 'click', startStandardMode );
-	standardModeButton.setAttribute( 'disabled', 'disabled' );
-
 	await reloadEditor( {
 		plugins: [ ArticlePluginSet, Table, RestrictedEditingException ],
 		toolbar: [
@@ -51,22 +47,13 @@ async function startStandardMode() {
 			]
 		}
 	} );
-
-	currentModeDisplay.innerHTML = 'Current Mode: <span class="mode mode-standard">STANDARD</span>';
-	enableSwitchToRestrictedMode();
 }
 
 async function startRestrictedMode() {
-	restrictedModeButton.removeEventListener( 'click', startRestrictedMode );
-	restrictedModeButton.setAttribute( 'disabled', 'disabled' );
-
 	await reloadEditor( {
 		plugins: [ ArticlePluginSet, Table, RestrictedEditing ],
 		toolbar: [ 'bold', 'italic', 'link', '|', 'restrictedEditing', '|', 'undo', 'redo' ]
 	} );
-
-	currentModeDisplay.innerHTML = 'Current Mode: <span class="mode mode-restricted">RESTRICTED</span>';
-	enableSwitchToStandardMode();
 }
 
 async function reloadEditor( config ) {

+ 18 - 19
packages/ckeditor5-restricted-editing/tests/restrictedediting.js

@@ -9,6 +9,7 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 
 import RestrictedEditing from './../src/restrictedediting';
+import RestrictedEditingUI from './../src/restrictededitingui';
 import RestrictedEditingEditing from './../src/restrictededitingediting';
 
 describe( 'RestrictedEditing', () => {
@@ -16,30 +17,28 @@ describe( 'RestrictedEditing', () => {
 
 	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 ] } );
-		} );
+		editor = await ClassicTestEditor.create( element, { plugins: [ RestrictedEditing ] } );
+	} );
 
-		afterEach( () => {
-			element.remove();
+	afterEach( () => {
+		element.remove();
 
-			return editor.destroy();
-		} );
+		return editor.destroy();
+	} );
 
-		it( 'should be named', () => {
-			expect( RestrictedEditing.pluginName ).to.equal( 'RestrictedEditing' );
-		} );
+	it( 'should be named', () => {
+		expect( RestrictedEditing.pluginName ).to.equal( 'RestrictedEditing' );
+	} );
 
-		it( 'should be loaded', () => {
-			expect( editor.plugins.get( RestrictedEditing ) ).to.be.instanceOf( RestrictedEditing );
-		} );
+	it( 'should load the RestrictedEditingEditing plugin', () => {
+		expect( editor.plugins.get( RestrictedEditingEditing ) ).to.be.instanceOf( RestrictedEditingEditing );
+	} );
 
-		it( 'should loaded RestrictedEditingEditing plugin', () => {
-			expect( editor.plugins.get( RestrictedEditingEditing ) ).to.be.instanceOf( RestrictedEditingEditing );
-		} );
+	it( 'should load the RestrictedEditingUI plugin', () => {
+		expect( editor.plugins.get( RestrictedEditingUI ) ).to.be.instanceOf( RestrictedEditingUI );
 	} );
 } );

+ 442 - 8
packages/ckeditor5-restricted-editing/tests/restrictededitingediting.js

@@ -3,26 +3,37 @@
  * 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 VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-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 ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 
 import RestrictedEditingEditing from './../src/restrictededitingediting';
+import RestrictedEditingNavigationCommand from '../src/restrictededitingnavigationcommand';
+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 { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard';
+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;
+	let editor, element;
 
 	testUtils.createSinonSandbox();
 
 	describe( 'plugin', () => {
 		beforeEach( async () => {
-			editor = await VirtualTestEditor.create( { plugins: [ RestrictedEditingEditing ] } );
+			element = document.createElement( 'div' );
+			document.body.appendChild( element );
+
+			editor = await ClassicTestEditor.create( element, { plugins: [ RestrictedEditingEditing ] } );
 		} );
 
-		afterEach( async () => {
-			await editor.destroy();
+		afterEach( () => {
+			element.remove();
+
+			return editor.destroy();
 		} );
 
 		it( 'should be named', () => {
@@ -32,6 +43,14 @@ describe( 'RestrictedEditingEditing', () => {
 		it( 'should be loaded', () => {
 			expect( editor.plugins.get( RestrictedEditingEditing ) ).to.be.instanceOf( RestrictedEditingEditing );
 		} );
+
+		it( 'adds a "goToPreviousRestrictedEditingRegion" command', () => {
+			expect( editor.commands.get( 'goToPreviousRestrictedEditingRegion' ) ).to.be.instanceOf( RestrictedEditingNavigationCommand );
+		} );
+
+		it( 'adds a "goToNextRestrictedEditingRegion" command', () => {
+			expect( editor.commands.get( 'goToNextRestrictedEditingRegion' ) ).to.be.instanceOf( RestrictedEditingNavigationCommand );
+		} );
 	} );
 
 	describe( 'conversion', () => {
@@ -120,4 +139,419 @@ describe( 'RestrictedEditingEditing', () => {
 			} );
 		} );
 	} );
+
+	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>'
+				);
+			} );
+		} );
+	} );
+
+	describe( 'exception cycling with the keyboard', () => {
+		let model, view, domEvtDataStub;
+
+		beforeEach( async () => {
+			editor = await VirtualTestEditor.create( {
+				plugins: [ Paragraph, RestrictedEditingEditing, BoldEditing ]
+			} );
+
+			model = editor.model;
+			view = editor.editing.view;
+
+			domEvtDataStub = {
+				keyCode: getCode( 'Tab' ),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+
+			sinon.spy( editor, 'execute' );
+		} );
+
+		afterEach( () => {
+			return editor.destroy();
+		} );
+
+		it( 'should move to the closest next exception on tab key', () => {
+			setModelData( model, '<paragraph>[]foo bar baz qux</paragraph>' );
+
+			const paragraph = model.document.getRoot().getChild( 0 );
+
+			// <paragraph>[]foo <marker>bar</marker> baz qux</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
+				} );
+			} );
+
+			// <paragraph>[]foo <marker>bar</marker> <marker>baz</marker≥ qux</paragraph>
+			model.change( writer => {
+				writer.addMarker( 'restricted-editing-exception:2', {
+					range: writer.createRange( writer.createPositionAt( paragraph, 8 ), writer.createPositionAt( paragraph, 11 ) ),
+					usingOperation: true,
+					affectsData: true
+				} );
+			} );
+
+			view.document.fire( 'keydown', domEvtDataStub );
+
+			sinon.assert.calledOnce( editor.execute );
+			sinon.assert.calledWithExactly( editor.execute, 'goToNextRestrictedEditingRegion' );
+			sinon.assert.calledOnce( domEvtDataStub.preventDefault );
+			sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
+		} );
+
+		it( 'should not move to the closest next exception on tab key when there is none', () => {
+			setModelData( model, '<paragraph>foo qux[]</paragraph>' );
+
+			const paragraph = model.document.getRoot().getChild( 0 );
+
+			// <paragraph><marker>foo</marker> qux[]</paragraph>
+			model.change( writer => {
+				writer.addMarker( 'restricted-editing-exception:1', {
+					range: writer.createRange( writer.createPositionAt( paragraph, 0 ), writer.createPositionAt( paragraph, 3 ) ),
+					usingOperation: true,
+					affectsData: true
+				} );
+			} );
+
+			view.document.fire( 'keydown', domEvtDataStub );
+
+			sinon.assert.notCalled( editor.execute );
+			sinon.assert.calledOnce( domEvtDataStub.preventDefault );
+			sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
+		} );
+
+		it( 'should move to the closest previous exception on shift+tab key', () => {
+			setModelData( model, '<paragraph>foo bar baz qux[]</paragraph>' );
+
+			const paragraph = model.document.getRoot().getChild( 0 );
+
+			// <paragraph>foo <marker>bar</marker> baz qux[]</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
+				} );
+			} );
+
+			// <paragraph>foo <marker>bar</marker> <marker>baz</marker≥ qux[]</paragraph>
+			model.change( writer => {
+				writer.addMarker( 'restricted-editing-exception:2', {
+					range: writer.createRange( writer.createPositionAt( paragraph, 8 ), writer.createPositionAt( paragraph, 11 ) ),
+					usingOperation: true,
+					affectsData: true
+				} );
+			} );
+
+			domEvtDataStub.keyCode += getCode( 'Shift' );
+			view.document.fire( 'keydown', domEvtDataStub );
+
+			sinon.assert.calledOnce( editor.execute );
+			sinon.assert.calledWithExactly( editor.execute, 'goToPreviousRestrictedEditingRegion' );
+			sinon.assert.calledOnce( domEvtDataStub.preventDefault );
+			sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
+		} );
+
+		it( 'should not move to the closest previous exception on shift+tab key when there is none', () => {
+			setModelData( model, '<paragraph>[]foo qux</paragraph>' );
+
+			const paragraph = model.document.getRoot().getChild( 0 );
+
+			// <paragraph>[]foo <marker>qux</marker></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
+				} );
+			} );
+
+			domEvtDataStub.keyCode += getCode( 'Shift' );
+			view.document.fire( 'keydown', domEvtDataStub );
+
+			sinon.assert.notCalled( editor.execute );
+			sinon.assert.calledOnce( domEvtDataStub.preventDefault );
+			sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
+		} );
+	} );
 } );

+ 622 - 0
packages/ckeditor5-restricted-editing/tests/restrictededitingnavigationcommand.js

@@ -0,0 +1,622 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import RestrictedEditingNavigationCommand from '../src/restrictededitingnavigationcommand';
+
+describe( 'RestrictedEditingNavigationCommand', () => {
+	let editor, forwardCommand, backwardCommand, model;
+
+	beforeEach( () => {
+		return ModelTestEditor
+			.create()
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+
+				forwardCommand = new RestrictedEditingNavigationCommand( editor, 'forward' );
+				backwardCommand = new RestrictedEditingNavigationCommand( editor, 'backward' );
+
+				model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
+				editor.model.schema.extend( '$text', { allowAttributes: [ 'restrictedEditingException' ] } );
+			} );
+	} );
+
+	afterEach( () => {
+		forwardCommand.destroy();
+		backwardCommand.destroy();
+
+		return editor.destroy();
+	} );
+
+	describe( 'forward command', () => {
+		describe( 'isEnabled', () => {
+			describe( 'collapsed selection', () => {
+				it( 'should be true when there is a marker after the selection position', () => {
+					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( forwardCommand.isEnabled ).to.be.true;
+				} );
+
+				it( 'should be false when there is no marker after the selection position', () => {
+					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( forwardCommand.isEnabled ).to.be.false;
+				} );
+
+				it( 'should be false when the selection position is at a marker start and there are no more markers', () => {
+					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( forwardCommand.isEnabled ).to.be.false;
+				} );
+
+				it( 'should be false when the selection position is in a marker and there are no more markers', () => {
+					setModelData( model, '<paragraph>foo b[]ar baz</paragraph>' );
+
+					const paragraph = model.document.getRoot().getChild( 0 );
+
+					// <paragraph>foo <marker>b[]ar</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( forwardCommand.isEnabled ).to.be.false;
+				} );
+
+				it( 'should be false when the selection position is at a marker end and there are no more markers', () => {
+					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( forwardCommand.isEnabled ).to.be.false;
+				} );
+			} );
+
+			describe( 'expanded selection', () => {
+				it( 'should be true when there is a marker after the first selection position', () => {
+					setModelData( model, '<paragraph>[fo]o bar baz</paragraph>' );
+
+					const paragraph = model.document.getRoot().getChild( 0 );
+
+					// <paragraph>[fo]o <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( forwardCommand.isEnabled ).to.be.true;
+				} );
+
+				it( 'should be true when the selection overlaps the marker but the start position is before it', () => {
+					setModelData( model, '<paragraph>[foo ba]r baz</paragraph>' );
+
+					const paragraph = model.document.getRoot().getChild( 0 );
+
+					// <paragraph>[foo <marker>ba]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( forwardCommand.isEnabled ).to.be.true;
+				} );
+
+				it( 'should be false when the selection overlaps the marker but the start position is after it', () => {
+					setModelData( model, '<paragraph>foo ba[r baz]</paragraph>' );
+
+					const paragraph = model.document.getRoot().getChild( 0 );
+
+					// <paragraph>foo <marker>ba[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( forwardCommand.isEnabled ).to.be.false;
+				} );
+			} );
+		} );
+
+		describe( 'execute()', () => {
+			describe( 'collapsed selection', () => {
+				it( 'should move to the next marker', () => {
+					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
+						} );
+					} );
+
+					// <paragraph>[]foo <marker>bar</marker> <marker>baz</marker></paragraph>
+					model.change( writer => {
+						writer.addMarker( 'restricted-editing-exception:2', {
+							range: writer.createRange( writer.createPositionAt( paragraph, 8 ), writer.createPositionAt( paragraph, 11 ) ),
+							usingOperation: true,
+							affectsData: true
+						} );
+					} );
+
+					forwardCommand.execute();
+					expect( getModelData( model ) ).to.equal( '<paragraph>foo [bar] baz</paragraph>' );
+
+					forwardCommand.execute();
+					expect( getModelData( model ) ).to.equal( '<paragraph>foo bar [baz]</paragraph>' );
+				} );
+
+				it( 'should move to the next marker when at the end of adjacent one', () => {
+					setModelData( model, '<paragraph>foo[]</paragraph><paragraph>bar</paragraph>' );
+
+					const fiirstParagraph = model.document.getRoot().getChild( 0 );
+					const secondParagraph = model.document.getRoot().getChild( 1 );
+
+					// <paragraph><marker>foo</marker>[]</paragraph><paragraph>bar</paragraph>
+					model.change( writer => {
+						writer.addMarker( 'restricted-editing-exception:1', {
+							range: writer.createRangeIn( fiirstParagraph ),
+							usingOperation: true,
+							affectsData: true
+						} );
+					} );
+
+					// <paragraph><marker>foo</marker>[]</paragraph><paragraph><marker>bar</marker></paragraph>
+					model.change( writer => {
+						writer.addMarker( 'restricted-editing-exception:2', {
+							range: writer.createRangeIn( secondParagraph ),
+							usingOperation: true,
+							affectsData: true
+						} );
+					} );
+
+					forwardCommand.execute();
+					expect( getModelData( model ) ).to.equal( '<paragraph>foo</paragraph><paragraph>[bar]</paragraph>' );
+				} );
+
+				it( 'should move to the closest marker when created in a reverse order', () => {
+					setModelData( model, '<paragraph>[]foo bar baz</paragraph>' );
+
+					const paragraph = model.document.getRoot().getChild( 0 );
+
+					// <paragraph>[]foo bar <marker>baz</marker></paragraph>
+					model.change( writer => {
+						writer.addMarker( 'restricted-editing-exception:2', {
+							range: writer.createRange( writer.createPositionAt( paragraph, 8 ), writer.createPositionAt( paragraph, 11 ) ),
+							usingOperation: true,
+							affectsData: true
+						} );
+					} );
+
+					// <paragraph>[]foo <marker>bar</marker> <marker>baz</marker></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
+						} );
+					} );
+
+					forwardCommand.execute();
+					expect( getModelData( model ) ).to.equal( '<paragraph>foo [bar] baz</paragraph>' );
+
+					forwardCommand.execute();
+					expect( getModelData( model ) ).to.equal( '<paragraph>foo bar [baz]</paragraph>' );
+				} );
+			} );
+
+			describe( 'expanded selection', () => {
+				it( 'should move to the next marker when the selection end overlaps the marker', () => {
+					setModelData( model, '<paragraph>[foo b]ar baz</paragraph>' );
+
+					const paragraph = model.document.getRoot().getChild( 0 );
+
+					// <paragraph>[foo <marker>b]ar</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
+						} );
+					} );
+
+					// <paragraph>[foo <marker>b]ar</marker> <marker>baz</marker></paragraph>
+					model.change( writer => {
+						writer.addMarker( 'restricted-editing-exception:2', {
+							range: writer.createRange( writer.createPositionAt( paragraph, 8 ), writer.createPositionAt( paragraph, 11 ) ),
+							usingOperation: true,
+							affectsData: true
+						} );
+					} );
+
+					forwardCommand.execute();
+					expect( getModelData( model ) ).to.equal( '<paragraph>foo [bar] baz</paragraph>' );
+
+					forwardCommand.execute();
+					expect( getModelData( model ) ).to.equal( '<paragraph>foo bar [baz]</paragraph>' );
+				} );
+
+				it( 'should move to the next marker when the selection start overlaps the marker', () => {
+					setModelData( model, '<paragraph>foo b[ar b]az</paragraph>' );
+
+					const paragraph = model.document.getRoot().getChild( 0 );
+
+					// <paragraph>foo <marker>b[ar</marker> b]az</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
+						} );
+					} );
+
+					// <paragraph>foo <marker>b[ar</marker> <marker>b]az</marker></paragraph>
+					model.change( writer => {
+						writer.addMarker( 'restricted-editing-exception:2', {
+							range: writer.createRange( writer.createPositionAt( paragraph, 8 ), writer.createPositionAt( paragraph, 11 ) ),
+							usingOperation: true,
+							affectsData: true
+						} );
+					} );
+
+					forwardCommand.execute();
+					expect( getModelData( model ) ).to.equal( '<paragraph>foo bar [baz]</paragraph>' );
+				} );
+			} );
+		} );
+	} );
+
+	describe( 'backward command', () => {
+		describe( 'isEnabled', () => {
+			describe( 'collapsed selection', () => {
+				it( 'should be true when there is a marker before the selection position', () => {
+					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( backwardCommand.isEnabled ).to.be.true;
+				} );
+
+				it( 'should be false when there is no marker before the selection position', () => {
+					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( backwardCommand.isEnabled ).to.be.false;
+				} );
+
+				it( 'should be false when the selection position is at a marker end and there are no more markers', () => {
+					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( backwardCommand.isEnabled ).to.be.false;
+				} );
+
+				it( 'should be false when the selection position is in a marker and there are no more markers', () => {
+					setModelData( model, '<paragraph>foo b[]ar baz</paragraph>' );
+
+					const paragraph = model.document.getRoot().getChild( 0 );
+
+					// <paragraph>foo <marker>b[]ar</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( backwardCommand.isEnabled ).to.be.false;
+				} );
+
+				it( 'should be false when the selection position is at a marker start and there are no more markers', () => {
+					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( backwardCommand.isEnabled ).to.be.false;
+				} );
+			} );
+
+			describe( 'expanded selection', () => {
+				it( 'should be true when there is a marker before the first selection position', () => {
+					setModelData( model, '<paragraph>foo bar b[az]</paragraph>' );
+
+					const paragraph = model.document.getRoot().getChild( 0 );
+
+					// <paragraph>[fo]o <marker>bar</marker> b[az]</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( backwardCommand.isEnabled ).to.be.true;
+				} );
+
+				it( 'should be false when the selection overlaps the marker but the start position is after it', () => {
+					setModelData( model, '<paragraph>foo b[ar baz]</paragraph>' );
+
+					const paragraph = model.document.getRoot().getChild( 0 );
+
+					// <paragraph>foo <marker>b[ar</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( backwardCommand.isEnabled ).to.be.false;
+				} );
+
+				it( 'should be false when the selection overlaps the marker but the after position is after it', () => {
+					setModelData( model, '<paragraph>[foo b]ar baz</paragraph>' );
+
+					const paragraph = model.document.getRoot().getChild( 0 );
+
+					// <paragraph>[foo <marker>b]ar</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( backwardCommand.isEnabled ).to.be.false;
+				} );
+			} );
+		} );
+
+		describe( 'execute()', () => {
+			describe( 'collapsed selection', () => {
+				it( 'should move to the previous marker', () => {
+					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
+						} );
+					} );
+
+					// <paragraph>foo <marker>bar</marker> <marker>baz</marker>[]</paragraph>
+					model.change( writer => {
+						writer.addMarker( 'restricted-editing-exception:2', {
+							range: writer.createRange( writer.createPositionAt( paragraph, 8 ), writer.createPositionAt( paragraph, 11 ) ),
+							usingOperation: true,
+							affectsData: true
+						} );
+					} );
+
+					backwardCommand.execute();
+					expect( getModelData( model ) ).to.equal( '<paragraph>foo [bar] baz</paragraph>' );
+				} );
+
+				it( 'should move to the previous marker when at the beginning of adjacent one', () => {
+					setModelData( model, '<paragraph>foo</paragraph><paragraph>[]bar</paragraph>' );
+
+					const fiirstParagraph = model.document.getRoot().getChild( 0 );
+					const secondParagraph = model.document.getRoot().getChild( 1 );
+
+					// <paragraph><marker>foo</marker></paragraph><paragraph>[]bar</paragraph>
+					model.change( writer => {
+						writer.addMarker( 'restricted-editing-exception:1', {
+							range: writer.createRangeIn( fiirstParagraph ),
+							usingOperation: true,
+							affectsData: true
+						} );
+					} );
+
+					// <paragraph><marker>foo</marker></paragraph><paragraph><marker>[]bar</marker></paragraph>
+					model.change( writer => {
+						writer.addMarker( 'restricted-editing-exception:2', {
+							range: writer.createRangeIn( secondParagraph ),
+							usingOperation: true,
+							affectsData: true
+						} );
+					} );
+
+					backwardCommand.execute();
+					expect( getModelData( model ) ).to.equal( '<paragraph>[foo]</paragraph><paragraph>bar</paragraph>' );
+				} );
+
+				it( 'should move to the closest previous marker', () => {
+					setModelData( model, '<paragraph>foo bar baz qux[]</paragraph>' );
+
+					const paragraph = model.document.getRoot().getChild( 0 );
+
+					// <paragraph>foo <marker>bar</marker> baz qux[]</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
+						} );
+					} );
+
+					// <paragraph>foo <marker>bar</marker> <marker>baz</marker> qux[]</paragraph>
+					model.change( writer => {
+						writer.addMarker( 'restricted-editing-exception:2', {
+							range: writer.createRange( writer.createPositionAt( paragraph, 8 ), writer.createPositionAt( paragraph, 11 ) ),
+							usingOperation: true,
+							affectsData: true
+						} );
+					} );
+
+					backwardCommand.execute();
+					expect( getModelData( model ) ).to.equal( '<paragraph>foo bar [baz] qux</paragraph>' );
+
+					backwardCommand.execute();
+					expect( getModelData( model ) ).to.equal( '<paragraph>foo [bar] baz qux</paragraph>' );
+				} );
+
+				it( 'should move to the closest previous marker when created in a reverse order', () => {
+					setModelData( model, '<paragraph>foo bar baz qux[]</paragraph>' );
+
+					const paragraph = model.document.getRoot().getChild( 0 );
+
+					// <paragraph>foo bar <marker>baz</marker> qux[]</paragraph>
+					model.change( writer => {
+						writer.addMarker( 'restricted-editing-exception:2', {
+							range: writer.createRange( writer.createPositionAt( paragraph, 8 ), writer.createPositionAt( paragraph, 11 ) ),
+							usingOperation: true,
+							affectsData: true
+						} );
+					} );
+
+					// <paragraph>foo <marker>bar</marker> <marker>baz</marker> qux[]</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
+						} );
+					} );
+
+					backwardCommand.execute();
+					expect( getModelData( model ) ).to.equal( '<paragraph>foo bar [baz] qux</paragraph>' );
+
+					backwardCommand.execute();
+					expect( getModelData( model ) ).to.equal( '<paragraph>foo [bar] baz qux</paragraph>' );
+				} );
+			} );
+
+			describe( 'expanded selection', () => {
+				it( 'should move to the previous marker when the selection end overlaps the marker', () => {
+					setModelData( model, '<paragraph>foo bar b[az]</paragraph>' );
+
+					const paragraph = model.document.getRoot().getChild( 0 );
+
+					// <paragraph>foo <marker>bar</marker> b[az]</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
+						} );
+					} );
+
+					// <paragraph>foo <marker>bar</marker> <marker>b[az</marker>]</paragraph>
+					model.change( writer => {
+						writer.addMarker( 'restricted-editing-exception:2', {
+							range: writer.createRange( writer.createPositionAt( paragraph, 8 ), writer.createPositionAt( paragraph, 11 ) ),
+							usingOperation: true,
+							affectsData: true
+						} );
+					} );
+
+					backwardCommand.execute();
+					expect( getModelData( model ) ).to.equal( '<paragraph>foo [bar] baz</paragraph>' );
+				} );
+			} );
+		} );
+	} );
+} );

+ 134 - 0
packages/ckeditor5-restricted-editing/tests/restrictededitingui.js

@@ -0,0 +1,134 @@
+/**
+ * @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 RestrictedEditingUI from './../src/restrictededitingui';
+import lockIcon from '../theme/icons/contentlock.svg';
+
+describe( 'RestrictedEditingUI', () => {
+	let editor, element, goToPreviousCommand, goToNextCommand;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		element = document.createElement( 'div' );
+		document.body.appendChild( element );
+
+		return ClassicTestEditor
+			.create( element, {
+				plugins: [ RestrictedEditingEditing, RestrictedEditingUI ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+
+				goToPreviousCommand = editor.commands.get( 'goToPreviousRestrictedEditingRegion' );
+				goToNextCommand = editor.commands.get( 'goToNextRestrictedEditingRegion' );
+			} );
+	} );
+
+	afterEach( () => {
+		element.remove();
+
+		return editor.destroy();
+	} );
+
+	describe( 'plugin', () => {
+		it( 'should be named', () => {
+			expect( RestrictedEditingUI.pluginName ).to.equal( 'RestrictedEditingUI' );
+		} );
+
+		it( 'should be loaded', () => {
+			expect( editor.plugins.get( RestrictedEditingUI ) ).to.be.instanceOf( RestrictedEditingUI );
+		} );
+	} );
+
+	describe( 'restricted editing dropdown', () => {
+		let dropdown;
+
+		beforeEach( () => {
+			dropdown = editor.ui.componentFactory.create( 'restrictedEditing' );
+		} );
+
+		it( 'the button should have basic properties', () => {
+			const button = dropdown.buttonView;
+
+			expect( button ).to.have.property( 'label', 'Navigate editable regions' );
+			expect( button ).to.have.property( 'tooltip', true );
+			expect( button ).to.have.property( 'icon', lockIcon );
+			expect( button ).to.have.property( 'isEnabled', true );
+			expect( button ).to.have.property( 'isOn', false );
+		} );
+
+		describe( 'exceptions navigation buttons', () => {
+			it( 'should have one that goes backward', () => {
+				const list = dropdown.listView;
+				const button = list.items.first.children.first;
+
+				expect( button.isOn ).to.be.false;
+				expect( button.withText ).to.be.true;
+				expect( button.withKeystroke ).to.be.true;
+				expect( button.label ).to.equal( 'Previous editable region' );
+				expect( button.keystroke ).to.equal( 'Shift+Tab' );
+			} );
+
+			it( 'should have one that goes forward', () => {
+				const list = dropdown.listView;
+				const button = list.items.last.children.first;
+
+				expect( button.isOn ).to.be.false;
+				expect( button.withText ).to.be.true;
+				expect( button.withKeystroke ).to.be.true;
+				expect( button.label ).to.equal( 'Next editable region' );
+				expect( button.keystroke ).to.equal( 'Tab' );
+			} );
+
+			it( 'should focus the view after executing the command', () => {
+				const focusSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
+				const list = dropdown.listView;
+				const goToPreviousButton = list.items.first.children.first;
+
+				goToPreviousButton.fire( 'execute' );
+				sinon.assert.calledOnce( focusSpy );
+			} );
+
+			it( 'be enabled just like their corresponding commands', () => {
+				const listView = dropdown.listView;
+
+				goToPreviousCommand.isEnabled = false;
+				goToNextCommand.isEnabled = false;
+
+				expect( listView.items.map( item => item.children.first.isEnabled ) ).to.deep.equal( [ false, false ] );
+
+				goToPreviousCommand.isEnabled = true;
+				expect( listView.items.map( item => item.children.first.isEnabled ) ).to.deep.equal( [ true, false ] );
+
+				goToNextCommand.isEnabled = true;
+				expect( listView.items.map( item => item.children.first.isEnabled ) ).to.deep.equal( [ true, true ] );
+			} );
+
+			it( 'should execute their corresponding commands', () => {
+				const list = dropdown.listView;
+				const goToPreviousButton = list.items.first.children.first;
+				const goToNextButton = list.items.last.children.first;
+
+				goToPreviousCommand.isEnabled = true;
+				goToNextCommand.isEnabled = true;
+
+				const spy = sinon.spy( editor, 'execute' );
+
+				goToPreviousButton.fire( 'execute' );
+				sinon.assert.calledWith( spy.firstCall, 'goToPreviousRestrictedEditingRegion' );
+
+				goToNextButton.fire( 'execute' );
+				sinon.assert.calledWith( spy.secondCall, 'goToNextRestrictedEditingRegion' );
+			} );
+		} );
+	} );
+} );

+ 1 - 1
packages/ckeditor5-restricted-editing/theme/icons/contentlock.svg

@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M2.15 3.93c0 .4.33.75.75.75h11.78a.75.75 0 100-1.5H2.9a.75.75 0 00-.75.75zm.75 8.75h3a.75.75 0 000-1.5h-3a.75.75 0 100 1.5zm-.75 3.25c0 .4.33.75.75.75h2.94a.75.75 0 000-1.5H2.9a.75.75 0 00-.75.75zm.75-7.25h4.96a.75.75 0 000-1.5H2.9a.75.75 0 100 1.5zm11.25-2.12a3.01 3.01 0 013 3l.01 1.53h.3c.58 0 1.04.46 1.04 1.03v4.54c0 .57-.46 1.03-1.03 1.03h-6.44c-.57 0-1.03-.46-1.03-1.03v-4.54c0-.57.46-1.03 1.03-1.03h.11V9.57a3.01 3.01 0 013-3.01zm.1 6.64a.5.5 0 00-.5.5v1.5a.5.5 0 101 0v-1.5a.5.5 0 00-.5-.5zm-.1-5.54a1.9 1.9 0 00-1.9 1.9v1.53h3.8V9.56a1.9 1.9 0 00-1.9-1.9z"/></svg>
+<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><g><path d="M15.5 6.5a3.5 3.5 0 013.495 3.308L19 10v2a1 1 0 011 1v5a1 1 0 01-1 1h-7a1 1 0 01-1-1v-5a1 1 0 011-1v-2l.005-.192A3.5 3.5 0 0115.5 6.5zm0 7.5a.5.5 0 00-.492.41L15 14.5v2a.5.5 0 00.992.09L16 16.5v-2a.5.5 0 00-.5-.5zm0-6a2 2 0 00-2 2v2h4v-2a2 2 0 00-2-2zM6.25 16a.75.75 0 110 1.5H.75a.75.75 0 110-1.5h5.5zm0-5a.75.75 0 110 1.5H.75a.75.75 0 110-1.5h5.5zm3-5a.75.75 0 010 1.5H.75a.75.75 0 010-1.5h8.5zm6-5a.75.75 0 110 1.5H.75a.75.75 0 010-1.5h14.5z"/></g></svg>

+ 1 - 0
packages/ckeditor5-restricted-editing/theme/icons/contentunlock.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><g><path d="M6.25 16a.75.75 0 110 1.5H.75a.75.75 0 110-1.5h5.5zm0-5a.75.75 0 110 1.5H.75a.75.75 0 110-1.5h5.5zm3-5a.75.75 0 010 1.5H.75a.75.75 0 010-1.5h8.5zm6-5a.75.75 0 110 1.5H.75a.75.75 0 010-1.5h14.5zM15.5 6.5a3.5 3.5 0 013.143 1.959.75.75 0 01-1.36.636A2 2 0 0013.5 10v2H19a1 1 0 011 1v5a1 1 0 01-1 1h-7a1 1 0 01-1-1v-5a1 1 0 011-1v-2l.005-.192A3.5 3.5 0 0115.5 6.5zm0 7.5a.5.5 0 00-.492.41L15 14.5v2a.5.5 0 00.992.09L16 16.5v-2a.5.5 0 00-.5-.5z"/></g></svg>

+ 44 - 0
packages/ckeditor5-restricted-editing/theme/restrictedediting.css

@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/*
+ *                                     !! IMPORTANT & TODO !!
+ *
+ * This file contains all kinds of style and some of them should land in theme-lark before going to prod.
+ *                  For now, let's keep them here to avoid additional branch and PR.
+ */
+
+:root {
+	--ck-restricted-editing-color-exception-background: hsla(31, 100%, 65%, .2);
+	--ck-restricted-editing-color-exception-brackets: hsla(31, 100%, 40%, .4);
+	--ck-restricted-editing-color-selected-exception-background: hsla(31, 100%, 65%, .5);
+	--ck-restricted-editing-color-selected-exception-brackets: hsla(31, 100%, 40%, .6);
+}
+
+.ck-editor__editable .ck-restricted-editing-exception {
+	transition: .2s ease-in-out background;
+	background-color: var(--ck-restricted-editing-color-exception-background);
+	border: 1px solid;
+	border-image: linear-gradient(
+		to right,
+		var(--ck-restricted-editing-color-exception-brackets) 0%,
+		var(--ck-restricted-editing-color-exception-brackets) 5px,
+		hsla(0, 0%, 0%, 0) 6px,
+		hsla(0, 0%, 0%, 0) calc(100% - 6px),
+		var(--ck-restricted-editing-color-exception-brackets) calc(100% - 5px),
+		var(--ck-restricted-editing-color-exception-brackets) 100%
+	) 1;
+
+	&.ck-restricted-editing-exception_selected {
+		background-color: var(--ck-restricted-editing-color-selected-exception-background);
+		border-image: linear-gradient(
+			to right,
+			var(--ck-restricted-editing-color-selected-exception-brackets) 0%,
+			var(--ck-restricted-editing-color-selected-exception-brackets) 5px,
+			var(--ck-restricted-editing-color-selected-exception-brackets) calc(100% - 5px),
+			var(--ck-restricted-editing-color-selected-exception-brackets) 100%
+		) 1;
+	}
+}