瀏覽代碼

Moved files from image feature.

Szymon Kupś 8 年之前
父節點
當前提交
7d0451bcb2

+ 4 - 0
packages/ckeditor5-widget/package.json

@@ -4,6 +4,10 @@
   "description": "Widget feature for CKEditor 5.",
   "keywords": [],
   "dependencies": {
+	  "@ckeditor/ckeditor5-core": "^0.7.0",
+	  "@ckeditor/ckeditor5-engine": "^0.8.0",
+	  "@ckeditor/ckeditor5-utils": "^0.8.0",
+	  "@ckeditor/ckeditor5-theme-lark": "^0.6.1"
   },
   "devDependencies": {
     "@ckeditor/ckeditor5-dev-lint": "^2.0.2",

+ 121 - 0
packages/ckeditor5-widget/src/utils.js

@@ -0,0 +1,121 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module widget/utils
+ */
+
+const widgetSymbol = Symbol( 'isWidget' );
+const labelSymbol = Symbol( 'label' );
+
+/**
+ * CSS class added to each widget element.
+ *
+ * @const {String}
+ */
+export const WIDGET_CLASS_NAME = 'ck-widget';
+
+/**
+ * CSS class added to currently selected widget element.
+ *
+ * @const {String}
+ */
+export const WIDGET_SELECTED_CLASS_NAME = 'ck-widget_selected';
+
+/**
+ * Returns `true` if given {@link module:engine/view/element~Element} is a widget.
+ *
+ * @param {module:engine/view/element~Element} element
+ * @returns {Boolean}
+ */
+export function isWidget( element ) {
+	return !!element.getCustomProperty( widgetSymbol );
+}
+
+/**
+ * Converts given {@link module:engine/view/element~Element} to widget in following way:
+ * * sets `contenteditable` attribute to `true`,
+ * * adds custom `getFillerOffset` method returning `null`,
+ * * adds `ck-widget` CSS class,
+ * * adds custom property allowing to recognize widget elements by using {@link ~isWidget}.
+ *
+ * @param {module:engine/view/element~Element} element
+ * @param {Object} [options]
+ * @param {String|Function} [options.label] Element's label provided to {@link ~setLabel} function. It can be passed as
+ * a plain string or a function returning a string.
+ * @returns {module:engine/view/element~Element} Returns same element.
+ */
+export function toWidget( element, options ) {
+	options = options || {};
+	element.setAttribute( 'contenteditable', false );
+	element.getFillerOffset = getFillerOffset;
+	element.addClass( WIDGET_CLASS_NAME );
+	element.setCustomProperty( widgetSymbol, true );
+
+	if ( options.label ) {
+		setLabel( element, options.label );
+	}
+
+	return element;
+}
+
+/**
+ * Sets label for given element.
+ * It can be passed as a plain string or a function returning a string. Function will be called each time label is retrieved by
+ * {@link ~getLabel}.
+ *
+ * @param {module:engine/view/element~Element} element
+ * @param {String|Function} labelOrCreator
+ */
+export function setLabel( element, labelOrCreator ) {
+	element.setCustomProperty( labelSymbol, labelOrCreator );
+}
+
+/**
+ * Returns label for provided element.
+ *
+ * @param {module:engine/view/element~Element} element
+ * @return {String}
+ */
+export function getLabel( element ) {
+	const labelCreator = element.getCustomProperty( labelSymbol );
+
+	if ( !labelCreator ) {
+		return '';
+	}
+
+	return typeof labelCreator == 'function' ? labelCreator() : labelCreator;
+}
+
+/**
+ * Adds functionality to provided {module:engine/view/editableelement~EditableElement} to act as a widget's editable:
+ * * sets `contenteditable` attribute to `true`,
+ * * adds `ck-editable` CSS class,
+ * * adds `ck-editable_focused` CSS class when editable is focused and removes it when it's blurred.
+ *
+ * @param {module:engine/view/editableelement~EditableElement} editable
+ * @returns {module:engine/view/editableelement~EditableElement} Returns same element that was provided in `editable` param.
+ */
+export function toWidgetEditable( editable ) {
+	editable.setAttribute( 'contenteditable', 'true' );
+	editable.addClass( 'ck-editable' );
+
+	editable.on( 'change:isFocused', ( evt, property, is ) => {
+		if ( is ) {
+			editable.addClass( 'ck-editable_focused' );
+		} else {
+			editable.removeClass( 'ck-editable_focused' );
+		}
+	} );
+
+	return editable;
+}
+
+// Default filler offset function applied to all widget elements.
+//
+// @returns {null}
+function getFillerOffset() {
+	return null;
+}

+ 262 - 0
packages/ckeditor5-widget/src/widget.js

@@ -0,0 +1,262 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module widget/widget
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import WidgetEngine from './widgetengine';
+import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver';
+import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range';
+import ModelSelection from '@ckeditor/ckeditor5-engine/src/model/selection';
+import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
+import ViewEditableElement from '@ckeditor/ckeditor5-engine/src/view/editableelement';
+import RootEditableElement from '@ckeditor/ckeditor5-engine/src/view/rooteditableelement';
+import { isWidget } from './utils';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+
+import '../theme/theme.scss';
+
+/**
+ * The widget plugin.
+ * Adds default {@link module:engine/view/document~Document#event:mousedown mousedown} handling on widget elements.
+ *
+ * @extends module:core/plugin~Plugin.
+ */
+export default class Widget extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ WidgetEngine ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const viewDocument = this.editor.editing.view;
+
+		// If mouse down is pressed on widget - create selection over whole widget.
+		viewDocument.addObserver( MouseObserver );
+		this.listenTo( viewDocument, 'mousedown', ( ...args ) => this._onMousedown( ...args ) );
+
+		// Handle custom keydown behaviour.
+		this.listenTo( viewDocument, 'keydown', ( ...args ) => this._onKeydown( ...args ), { priority: 'high' } );
+	}
+
+	/**
+	 * Handles {@link module:engine/view/document~Document#event:mousedown mousedown} events on widget elements.
+	 *
+	 * @private
+	 * @param {module:utils/eventinfo~EventInfo} eventInfo
+	 * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData
+	 */
+	_onMousedown( eventInfo, domEventData ) {
+		const editor = this.editor;
+		const viewDocument = editor.editing.view;
+		let element = domEventData.target;
+
+		// Do nothing if inside nested editable.
+		if ( isInsideNestedEditable( element ) ) {
+			return;
+		}
+
+		// If target is not a widget element - check if one of the ancestors is.
+		if ( !isWidget( element ) ) {
+			element = element.findAncestor( isWidget );
+
+			if ( !element ) {
+				return;
+			}
+		}
+
+		domEventData.preventDefault();
+
+		// Focus editor if is not focused already.
+		if ( !viewDocument.isFocused ) {
+			viewDocument.focus();
+		}
+
+		// Create model selection over widget.
+		const modelElement = editor.editing.mapper.toModelElement( element );
+
+		editor.document.enqueueChanges( ( ) => {
+			this._setSelectionOverElement( modelElement );
+		} );
+	}
+
+	/**
+	 * Handles {@link module:engine/view/document~Document#event:keydown keydown} events.
+	 *
+	 * @private
+	 * @param {module:utils/eventinfo~EventInfo} eventInfo
+	 * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData
+	 */
+	_onKeydown( eventInfo, domEventData  ) {
+		const keyCode = domEventData.keyCode;
+		const isForward = keyCode == keyCodes.delete || keyCode == keyCodes.arrowdown || keyCode == keyCodes.arrowright;
+
+		// Checks if delete/backspace or arrow keys were handled and then prevents default event behaviour and stops
+		// event propagation.
+		if ( ( isDeleteKeyCode( keyCode ) && this._handleDelete( isForward ) ) ||
+			( isArrowKeyCode( keyCode ) && this._handleArrowKeys( isForward ) ) ) {
+			domEventData.preventDefault();
+			eventInfo.stop();
+		}
+	}
+
+	/**
+	 * Handles delete keys: backspace and delete.
+	 *
+	 * @private
+	 * @param {Boolean} isForward Set to true if delete was performed in forward direction.
+	 * @returns {Boolean|undefined} Returns `true` if keys were handled correctly.
+	 */
+	_handleDelete( isForward ) {
+		const modelDocument = this.editor.document;
+		const modelSelection = modelDocument.selection;
+
+		// Do nothing on non-collapsed selection.
+		if ( !modelSelection.isCollapsed ) {
+			return;
+		}
+
+		const objectElement = this._getObjectElementNextToSelection( isForward );
+
+		if ( objectElement ) {
+			modelDocument.enqueueChanges( () => {
+				// Remove previous element if empty.
+				const previousNode = modelSelection.anchor.parent;
+
+				if ( previousNode.isEmpty ) {
+					const batch = modelDocument.batch();
+					batch.remove( previousNode );
+				}
+
+				this._setSelectionOverElement( objectElement );
+			} );
+
+			return true;
+		}
+	}
+
+	/**
+	 * Handles arrow keys.
+	 *
+	 * @param {Boolean} isForward Set to true if arrow key should be handled in forward direction.
+	 * @returns {Boolean|undefined} Returns `true` if keys were handled correctly.
+	 */
+	_handleArrowKeys( isForward ) {
+		const modelDocument = this.editor.document;
+		const schema = modelDocument.schema;
+		const modelSelection = modelDocument.selection;
+		const objectElement = modelSelection.getSelectedElement();
+
+		// if object element is selected.
+		if ( objectElement && schema.objects.has( objectElement.name ) ) {
+			const position = isForward ? modelSelection.getLastPosition() : modelSelection.getFirstPosition();
+			const newRange = modelDocument.getNearestSelectionRange( position, isForward ? 'forward' : 'backward' );
+
+			if ( newRange ) {
+				modelDocument.enqueueChanges( () => {
+					modelSelection.setRanges( [ newRange ] );
+				} );
+			}
+
+			return true;
+		}
+
+		// If selection is next to object element.
+		// Return if not collapsed.
+		if ( !modelSelection.isCollapsed ) {
+			return;
+		}
+
+		const objectElement2 = this._getObjectElementNextToSelection( isForward );
+
+		if ( objectElement2 instanceof ModelElement && modelDocument.schema.objects.has( objectElement2.name ) ) {
+			modelDocument.enqueueChanges( () => {
+				this._setSelectionOverElement( objectElement2 );
+			} );
+
+			return true;
+		}
+	}
+
+	/**
+	 * Sets {@link module:engine/model/selection~Selection document's selection} over given element.
+	 *
+	 * @private
+	 * @param {module:engine/model/element~Element} element
+	 */
+	_setSelectionOverElement( element ) {
+		this.editor.document.selection.setRanges( [ ModelRange.createOn( element ) ] );
+	}
+
+	/**
+	 * Checks if {@link module:engine/model/element~Element element} placed next to the current
+	 * {@link module:engine/model/selection~Selection model selection} exists and is marked in
+	 * {@link module:engine/model/schema~Schema schema} as `object`.
+	 *
+	 * @private
+	 * @param {Boolean} forward Direction of checking.
+	 * @returns {module:engine/model/element~Element|null}
+	 */
+	_getObjectElementNextToSelection( forward ) {
+		const modelDocument = this.editor.document;
+		const schema = modelDocument.schema;
+		const modelSelection = modelDocument.selection;
+		const dataController = this.editor.data;
+
+		// Clone current selection to use it as a probe. We must leave default selection as it is so it can return
+		// to its current state after undo.
+		const probe = ModelSelection.createFromSelection( modelSelection );
+		dataController.modifySelection( probe, { direction: forward ? 'forward' : 'backward' } );
+		const objectElement = forward ? probe.focus.nodeBefore : probe.focus.nodeAfter;
+
+		if ( objectElement instanceof ModelElement && schema.objects.has( objectElement.name ) ) {
+			return objectElement;
+		}
+
+		return null;
+	}
+}
+
+// Returns 'true' if provided key code represents one of the arrow keys.
+//
+// @param {Number} keyCode
+// @returns {Boolean}
+function isArrowKeyCode( keyCode ) {
+	return keyCode == keyCodes.arrowright ||
+		keyCode == keyCodes.arrowleft ||
+		keyCode == keyCodes.arrowup ||
+		keyCode == keyCodes.arrowdown;
+}
+
+//Returns 'true' if provided key code represents one of the delete keys: delete or backspace.
+//
+//@param {Number} keyCode
+//@returns {Boolean}
+function isDeleteKeyCode( keyCode ) {
+	return keyCode == keyCodes.delete || keyCode == keyCodes.backspace;
+}
+
+// Returns `true` when element is a nested editable or is placed inside one.
+//
+// @param {module:engine/view/element~Element}
+// @returns {Boolean}
+function isInsideNestedEditable( element ) {
+	while ( element ) {
+		if ( element instanceof ViewEditableElement && !( element instanceof RootEditableElement ) ) {
+			return true;
+		}
+
+		element = element.parent;
+	}
+
+	return false;
+}

+ 50 - 0
packages/ckeditor5-widget/src/widgetengine.js

@@ -0,0 +1,50 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module widget/widgetengine
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import { WIDGET_SELECTED_CLASS_NAME, isWidget, getLabel } from './utils';
+
+/**
+ * The widget engine plugin.
+ * Registers model to view selection converter for editing pipeline. It is hooked after default selection conversion.
+ * If converted selection is placed around widget element, selection is marked as fake. Additionally, proper CSS class
+ * is added to indicate that widget has been selected.
+ *
+ * @extends module:core/plugin~Plugin.
+ */
+export default class WidgetEngine extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		let previouslySelected;
+
+		// Model to view selection converter.
+		// Converts selection placed over widget element to fake selection
+		this.editor.editing.modelToView.on( 'selection', ( evt, data, consumable, conversionApi ) => {
+			// Remove selected class from previously selected widget.
+			if ( previouslySelected && previouslySelected.hasClass( WIDGET_SELECTED_CLASS_NAME ) ) {
+				previouslySelected.removeClass( WIDGET_SELECTED_CLASS_NAME );
+			}
+
+			const viewSelection = conversionApi.viewSelection;
+
+			// Check if widget was clicked or some sub-element.
+			const selectedElement = viewSelection.getSelectedElement();
+
+			if ( !selectedElement || !isWidget( selectedElement ) ) {
+				return;
+			}
+
+			viewSelection.setFake( true, { label: getLabel( selectedElement ) } );
+			selectedElement.addClass( WIDGET_SELECTED_CLASS_NAME );
+			previouslySelected = selectedElement;
+		}, { priority: 'low' } );
+	}
+}

+ 22 - 0
packages/ckeditor5-widget/tests/.jshintrc

@@ -0,0 +1,22 @@
+{
+  "esnext": true,
+  "expr": true,
+  "immed": true,
+  "loopfunc": true,
+  "noarg": true,
+  "nonbsp": true,
+  "strict": "implied",
+  "undef": true,
+  "unused": true,
+  "varstmt": true,
+  "globals": {
+    "after": false,
+    "afterEach": false,
+    "before": false,
+    "beforeEach": false,
+    "describe": false,
+    "expect": false,
+    "it": false,
+    "sinon": false
+  }
+}

+ 116 - 0
packages/ckeditor5-widget/tests/utils.js

@@ -0,0 +1,116 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ViewElement from '@ckeditor/ckeditor5-engine/src/view/element';
+import ViewEditableElement from '@ckeditor/ckeditor5-engine/src/view/editableelement';
+import ViewDocument from '@ckeditor/ckeditor5-engine/src/view/document';
+import {
+	toWidget,
+	isWidget,
+	setLabel,
+	getLabel,
+	toWidgetEditable,
+	WIDGET_CLASS_NAME
+} from '../src/utils';
+
+describe( 'widget utils', () => {
+	let element;
+
+	beforeEach( () => {
+		element = new ViewElement( 'div' );
+		toWidget( element );
+	} );
+
+	describe( 'toWidget()', () => {
+		it( 'should set contenteditable to false', () => {
+			expect( element.getAttribute( 'contenteditable' ) ).to.be.false;
+		} );
+
+		it( 'should define getFillerOffset method', () => {
+			expect( element.getFillerOffset ).to.be.function;
+			expect( element.getFillerOffset() ).to.be.null;
+		} );
+
+		it( 'should add proper CSS class', () => {
+			expect( element.hasClass( WIDGET_CLASS_NAME ) ).to.be.true;
+		} );
+
+		it( 'should add element\'s label if one is provided', () => {
+			element = new ViewElement( 'div' );
+			toWidget( element, { label: 'foo bar baz label' } );
+
+			expect( getLabel( element ) ).to.equal( 'foo bar baz label' );
+		} );
+
+		it( 'should add element\'s label if one is provided as function', () => {
+			element = new ViewElement( 'div' );
+			toWidget( element, { label: () => 'foo bar baz label' } );
+
+			expect( getLabel( element ) ).to.equal( 'foo bar baz label' );
+		} );
+	} );
+
+	describe( 'isWidget()', () => {
+		it( 'should return true for widgetized elements', () => {
+			expect( isWidget( element ) ).to.be.true;
+		} );
+
+		it( 'should return false for non-widgetized elements', () => {
+			expect( isWidget( new ViewElement( 'p' ) ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'label utils', () => {
+		it( 'should allow to set label for element', () => {
+			const element = new ViewElement( 'p' );
+			setLabel( element, 'foo bar baz' );
+
+			expect( getLabel( element ) ).to.equal( 'foo bar baz' );
+		} );
+
+		it( 'should return empty string for elements without label', () => {
+			const element = new ViewElement( 'div' );
+
+			expect( getLabel( element ) ).to.equal( '' );
+		} );
+
+		it( 'should allow to use a function as label creator', () => {
+			const element = new ViewElement( 'p' );
+			let caption = 'foo';
+			setLabel( element, () => caption );
+
+			expect( getLabel( element ) ).to.equal( 'foo' );
+			caption = 'bar';
+			expect( getLabel( element ) ).to.equal( 'bar' );
+		} );
+	} );
+
+	describe( 'toWidgetEditable', () => {
+		let viewDocument, element;
+
+		beforeEach( () => {
+			viewDocument = new ViewDocument();
+			element = new ViewEditableElement( 'div' );
+			element.document = viewDocument;
+			toWidgetEditable( element );
+		} );
+
+		it( 'should be created in context of proper document', () => {
+			expect( element.document ).to.equal( viewDocument );
+		} );
+
+		it( 'should add proper class', () => {
+			expect( element.hasClass( 'ck-editable' ) ).to.be.true;
+		} );
+
+		it( 'should add proper class when element is focused', () => {
+			element.isFocused = true;
+			expect( element.hasClass( 'ck-editable_focused' ) ).to.be.true;
+
+			element.isFocused = false;
+			expect( element.hasClass( 'ck-editable_focused' ) ).to.be.false;
+		} );
+	} );
+} );

+ 708 - 0
packages/ckeditor5-widget/tests/widget.js

@@ -0,0 +1,708 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import Widget from '../src/widget';
+import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver';
+import buildModelConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildmodelconverter';
+import { toWidget } from '../src/utils';
+import ViewContainer from '@ckeditor/ckeditor5-engine/src/view/containerelement';
+import ViewEditable from '@ckeditor/ckeditor5-engine/src/view/editableelement';
+import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
+import AttributeContainer from '@ckeditor/ckeditor5-engine/src/view/attributeelement';
+import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+
+/* global document */
+
+describe( 'Widget', () => {
+	let editor, doc, viewDocument;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( {
+			plugins: [ Widget ]
+		} )
+			.then( newEditor => {
+				editor = newEditor;
+				doc = editor.document;
+				viewDocument = editor.editing.view;
+
+				doc.schema.registerItem( 'widget', '$block' );
+				doc.schema.objects.add( 'widget' );
+				doc.schema.registerItem( 'paragraph', '$block' );
+				doc.schema.registerItem( 'inline', '$inline' );
+				doc.schema.objects.add( 'inline' );
+				doc.schema.registerItem( 'nested' );
+				doc.schema.allow( { name: '$inline', inside: 'nested' } );
+				doc.schema.allow( { name: 'nested', inside: 'widget' } );
+
+				buildModelConverter().for( editor.editing.modelToView )
+					.fromElement( 'paragraph' )
+					.toElement( 'p' );
+
+				buildModelConverter().for( editor.editing.modelToView )
+					.fromElement( 'widget' )
+					.toElement( () => {
+						const b = new AttributeContainer( 'b' );
+						const div = new ViewContainer( 'div', null, b );
+
+						return toWidget( div );
+					} );
+
+				buildModelConverter().for( editor.editing.modelToView )
+					.fromElement( 'inline' )
+					.toElement( 'figure' );
+
+				buildModelConverter().for( editor.editing.modelToView )
+					.fromElement( 'nested' )
+					.toElement( () => new ViewEditable( 'figcaption', { contenteditable: true } ) );
+			} );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( Widget ) ).to.be.instanceOf( Widget );
+	} );
+
+	it( 'should add MouseObserver', () => {
+		expect( editor.editing.view.getObserver( MouseObserver ) ).to.be.instanceof( MouseObserver );
+	} );
+
+	it( 'should create selection over clicked widget', () => {
+		setModelData( doc, '[]<widget></widget>' );
+		const viewDiv = viewDocument.getRoot().getChild( 0 );
+		const domEventDataMock = {
+			target: viewDiv,
+			preventDefault: sinon.spy()
+		};
+
+		viewDocument.fire( 'mousedown', domEventDataMock );
+
+		expect( getModelData( doc ) ).to.equal( '[<widget></widget>]' );
+		sinon.assert.calledOnce( domEventDataMock.preventDefault );
+	} );
+
+	it( 'should create selection when clicked in nested element', () => {
+		setModelData( doc, '[]<widget></widget>' );
+		const viewDiv = viewDocument.getRoot().getChild( 0 );
+		const viewB = viewDiv.getChild( 0 );
+		const domEventDataMock = {
+			target: viewB,
+			preventDefault: sinon.spy()
+		};
+
+		viewDocument.fire( 'mousedown', domEventDataMock );
+
+		expect( getModelData( doc ) ).to.equal( '[<widget></widget>]' );
+		sinon.assert.calledOnce( domEventDataMock.preventDefault );
+	} );
+
+	it( 'should do nothing if clicked inside nested editable', () => {
+		setModelData( doc, '[]<widget><nested>foo bar</nested></widget>' );
+		const viewDiv = viewDocument.getRoot().getChild( 0 );
+		const viewFigcaption = viewDiv.getChild( 0 );
+
+		const domEventDataMock = {
+			target: viewFigcaption,
+			preventDefault: sinon.spy()
+		};
+
+		viewDocument.fire( 'mousedown', domEventDataMock );
+
+		sinon.assert.notCalled( domEventDataMock.preventDefault );
+	} );
+
+	it( 'should do nothing if clicked in non-widget element', () => {
+		setModelData( doc, '<paragraph>[]foo bar</paragraph><widget></widget>' );
+		const viewP = viewDocument.getRoot().getChild( 0 );
+		const domEventDataMock = {
+			target: viewP,
+			preventDefault: sinon.spy()
+		};
+
+		viewDocument.focus();
+		viewDocument.fire( 'mousedown', domEventDataMock );
+
+		expect( getModelData( doc ) ).to.equal( '<paragraph>[]foo bar</paragraph><widget></widget>' );
+		sinon.assert.notCalled( domEventDataMock.preventDefault );
+	} );
+
+	it( 'should not focus editable if already is focused', () => {
+		setModelData( doc, '<widget></widget>' );
+		const widget = viewDocument.getRoot().getChild( 0 );
+		const domEventDataMock = {
+			target: widget,
+			preventDefault: sinon.spy()
+		};
+		const focusSpy = sinon.spy( viewDocument, 'focus' );
+
+		viewDocument.isFocused = true;
+		viewDocument.fire( 'mousedown', domEventDataMock );
+
+		sinon.assert.calledOnce( domEventDataMock.preventDefault );
+		sinon.assert.notCalled( focusSpy );
+		expect( getModelData( doc ) ).to.equal( '[<widget></widget>]' );
+	} );
+
+	describe( 'keys handling', () => {
+		describe( 'delete and backspace', () => {
+			test(
+				'should select widget when backspace is pressed',
+				'<widget></widget><paragraph>[]foo</paragraph>',
+				keyCodes.backspace,
+				'[<widget></widget>]<paragraph>foo</paragraph>'
+			);
+
+			test(
+				'should remove empty element after selecting widget when backspace is pressed',
+				'<widget></widget><paragraph>[]</paragraph>',
+				keyCodes.backspace,
+				'[<widget></widget>]'
+			);
+
+			test(
+				'should select widget when delete is pressed',
+				'<paragraph>foo[]</paragraph><widget></widget>',
+				keyCodes.delete,
+				'<paragraph>foo</paragraph>[<widget></widget>]'
+			);
+
+			test(
+				'should remove empty element after selecting widget when delete is pressed',
+				'<paragraph>[]</paragraph><widget></widget>',
+				keyCodes.delete,
+				'[<widget></widget>]'
+			);
+
+			test(
+				'should not respond to other keys',
+				'<widget></widget><paragraph>[]foo</paragraph>',
+				65,
+				'<widget></widget><paragraph>[]foo</paragraph>'
+			);
+
+			test(
+				'should do nothing on non-collapsed selection',
+				'<widget></widget><paragraph>[f]oo</paragraph>',
+				keyCodes.backspace,
+				'<widget></widget><paragraph>[f]oo</paragraph>'
+			);
+
+			test(
+				'should do nothing on non-object elements',
+				'<paragraph>foo</paragraph><paragraph>[]bar</paragraph>',
+				keyCodes.backspace,
+				'<paragraph>foo</paragraph><paragraph>[]bar</paragraph>'
+			);
+
+			test(
+				'should work correctly with modifier key: backspace + ctrl',
+				'<widget></widget><paragraph>[]foo</paragraph>',
+				{ keyCode: keyCodes.backspace, ctrlKey: true },
+				'[<widget></widget>]<paragraph>foo</paragraph>'
+			);
+
+			test(
+				'should work correctly with modifier key: backspace + alt',
+				'<widget></widget><paragraph>[]foo</paragraph>',
+				{ keyCode: keyCodes.backspace, altKey: true },
+				'[<widget></widget>]<paragraph>foo</paragraph>'
+			);
+
+			test(
+				'should work correctly with modifier key: backspace + shift',
+				'<widget></widget><paragraph>[]foo</paragraph>',
+				{ keyCode: keyCodes.backspace, shiftKey: true },
+				'[<widget></widget>]<paragraph>foo</paragraph>'
+			);
+
+			test(
+				'should work correctly with modifier key: delete + ctrl',
+				'<paragraph>foo[]</paragraph><widget></widget>',
+				{ keyCode: keyCodes.delete, ctrlKey: true },
+				'<paragraph>foo</paragraph>[<widget></widget>]'
+			);
+
+			test(
+				'should work correctly with modifier key: delete + alt',
+				'<paragraph>foo[]</paragraph><widget></widget>',
+				{ keyCode: keyCodes.delete, altKey: true },
+				'<paragraph>foo</paragraph>[<widget></widget>]'
+			);
+
+			test(
+				'should work correctly with modifier key: delete + shift',
+				'<paragraph>foo[]</paragraph><widget></widget>',
+				{ keyCode: keyCodes.delete, shiftKey: true },
+				'<paragraph>foo</paragraph>[<widget></widget>]'
+			);
+
+			test(
+				'should not modify backspace default behaviour in single paragraph boundaries',
+				'<paragraph>[]foo</paragraph>',
+				keyCodes.backspace,
+				'<paragraph>[]foo</paragraph>'
+			);
+
+			test(
+				'should not modify delete default behaviour in single paragraph boundaries',
+				'<paragraph>foo[]</paragraph>',
+				keyCodes.delete,
+				'<paragraph>foo[]</paragraph>'
+			);
+
+			test(
+				'should do nothing on selected widget preceded by a paragraph - backspace',
+				'<paragraph>foo</paragraph>[<widget></widget>]',
+				keyCodes.backspace,
+				'<paragraph>foo</paragraph>[<widget></widget>]'
+			);
+
+			test(
+				'should do nothing on selected widget preceded by another widget - backspace',
+				'<widget></widget>[<widget></widget>]',
+				keyCodes.backspace,
+				'<widget></widget>[<widget></widget>]'
+			);
+
+			test(
+				'should do nothing on selected widget before paragraph - backspace',
+				'[<widget></widget>]<paragraph>foo</paragraph>',
+				keyCodes.backspace,
+				'[<widget></widget>]<paragraph>foo</paragraph>'
+			);
+
+			test(
+				'should do nothing on selected widget before another widget - backspace',
+				'[<widget></widget>]<widget></widget>',
+				keyCodes.backspace,
+				'[<widget></widget>]<widget></widget>'
+			);
+
+			test(
+				'should do nothing on selected widget between paragraphs - backspace',
+				'<paragraph>bar</paragraph>[<widget></widget>]<paragraph>foo</paragraph>',
+				keyCodes.backspace,
+				'<paragraph>bar</paragraph>[<widget></widget>]<paragraph>foo</paragraph>'
+			);
+
+			test(
+				'should do nothing on selected widget between other widgets - backspace',
+				'<widget></widget>[<widget></widget>]<widget></widget>',
+				keyCodes.backspace,
+				'<widget></widget>[<widget></widget>]<widget></widget>'
+			);
+
+			test(
+				'should do nothing on selected widget preceded by a paragraph - delete',
+				'<paragraph>foo</paragraph>[<widget></widget>]',
+				keyCodes.delete,
+				'<paragraph>foo</paragraph>[<widget></widget>]'
+			);
+
+			test(
+				'should do nothing on selected widget preceded by another widget - delete',
+				'<widget></widget>[<widget></widget>]',
+				keyCodes.delete,
+				'<widget></widget>[<widget></widget>]'
+			);
+
+			test(
+				'should do nothing on selected widget before paragraph - delete',
+				'[<widget></widget>]<paragraph>foo</paragraph>',
+				keyCodes.delete,
+				'[<widget></widget>]<paragraph>foo</paragraph>'
+			);
+
+			test(
+				'should do nothing on selected widget before another widget - delete',
+				'[<widget></widget>]<widget></widget>',
+				keyCodes.delete,
+				'[<widget></widget>]<widget></widget>'
+			);
+
+			test(
+				'should do nothing on selected widget between paragraphs - delete',
+				'<paragraph>bar</paragraph>[<widget></widget>]<paragraph>foo</paragraph>',
+				keyCodes.delete,
+				'<paragraph>bar</paragraph>[<widget></widget>]<paragraph>foo</paragraph>'
+			);
+
+			test(
+				'should do nothing on selected widget between other widgets - delete',
+				'<widget></widget>[<widget></widget>]<widget></widget>',
+				keyCodes.delete,
+				'<widget></widget>[<widget></widget>]<widget></widget>'
+			);
+
+			test(
+				'should select inline objects - backspace',
+				'<paragraph>foo<inline></inline>[]bar</paragraph>',
+				keyCodes.backspace,
+				'<paragraph>foo[<inline></inline>]bar</paragraph>'
+			);
+
+			test(
+				'should select inline objects - delete',
+				'<paragraph>foo[]<inline></inline>bar</paragraph>',
+				keyCodes.delete,
+				'<paragraph>foo[<inline></inline>]bar</paragraph>'
+			);
+
+			test(
+				'should do nothing on selected inline objects - backspace',
+				'<paragraph>foo[<inline></inline>]bar</paragraph>',
+				keyCodes.backspace,
+				'<paragraph>foo[<inline></inline>]bar</paragraph>'
+			);
+
+			test(
+				'should do nothing on selected inline objects - delete',
+				'<paragraph>foo[<inline></inline>]bar</paragraph>',
+				keyCodes.delete,
+				'<paragraph>foo[<inline></inline>]bar</paragraph>'
+			);
+
+			test(
+				'should do nothing if selection is placed after first letter - backspace',
+				'<paragraph>a[]</paragraph>',
+				keyCodes.backspace,
+				'<paragraph>a[]</paragraph>'
+			);
+
+			test(
+				'should do nothing if selection is placed before first letter - delete',
+				'<paragraph>[]a</paragraph>',
+				keyCodes.delete,
+				'<paragraph>[]a</paragraph>'
+			);
+
+			it( 'should prevent default behaviour and stop event propagation', () => {
+				const keydownHandler = sinon.spy();
+				const domEventDataMock = {
+					keyCode: keyCodes.delete,
+					preventDefault: sinon.spy(),
+				};
+				setModelData( doc, '<paragraph>foo[]</paragraph><widget></widget>' );
+				viewDocument.on( 'keydown',  keydownHandler );
+
+				viewDocument.fire( 'keydown', domEventDataMock );
+
+				expect( getModelData( doc ) ).to.equal( '<paragraph>foo</paragraph>[<widget></widget>]' );
+				sinon.assert.calledOnce( domEventDataMock.preventDefault );
+				sinon.assert.notCalled( keydownHandler );
+			} );
+		} );
+
+		describe( 'arrows', () => {
+			test(
+				'should move selection forward from selected object - right arrow',
+				'[<widget></widget>]<paragraph>foo</paragraph>',
+				keyCodes.arrowright,
+				'<widget></widget><paragraph>[]foo</paragraph>'
+			);
+
+			test(
+				'should move selection forward from selected object - down arrow',
+				'[<widget></widget>]<paragraph>foo</paragraph>',
+				keyCodes.arrowdown,
+				'<widget></widget><paragraph>[]foo</paragraph>'
+			);
+
+			test(
+				'should move selection backward from selected object - left arrow',
+				'<paragraph>foo</paragraph>[<widget></widget>]',
+				keyCodes.arrowleft,
+				'<paragraph>foo[]</paragraph><widget></widget>'
+			);
+
+			test(
+				'should move selection backward from selected object - up arrow',
+				'<paragraph>foo</paragraph>[<widget></widget>]',
+				keyCodes.arrowup,
+				'<paragraph>foo[]</paragraph><widget></widget>'
+			);
+
+			test(
+				'should move selection to next widget - right arrow',
+				'[<widget></widget>]<widget></widget>',
+				keyCodes.arrowright,
+				'<widget></widget>[<widget></widget>]'
+			);
+
+			test(
+				'should move selection to next widget - down arrow',
+				'[<widget></widget>]<widget></widget>',
+				keyCodes.arrowdown,
+				'<widget></widget>[<widget></widget>]'
+			);
+
+			test(
+				'should move selection to previous widget - left arrow',
+				'<widget></widget>[<widget></widget>]',
+				keyCodes.arrowleft,
+				'[<widget></widget>]<widget></widget>'
+			);
+
+			test(
+				'should move selection to previous widget - up arrow',
+				'<widget></widget>[<widget></widget>]',
+				keyCodes.arrowup,
+				'[<widget></widget>]<widget></widget>'
+			);
+
+			test(
+				'should do nothing on non-collapsed selection next to object - right arrow',
+				'<paragraph>ba[r]</paragraph><widget></widget>',
+				keyCodes.arrowright,
+				'<paragraph>ba[r]</paragraph><widget></widget>'
+			);
+
+			test(
+				'should do nothing on non-collapsed selection next to object - down arrow',
+				'<paragraph>ba[r]</paragraph><widget></widget>',
+				keyCodes.arrowdown,
+				'<paragraph>ba[r]</paragraph><widget></widget>'
+			);
+
+			test(
+				'should do nothing on non-collapsed selection next to object - left arrow',
+				'<widget></widget><paragraph>[b]ar</paragraph>',
+				keyCodes.arrowleft,
+				'<widget></widget><paragraph>[b]ar</paragraph>'
+			);
+
+			test(
+				'should do nothing on non-collapsed selection next to object - up arrow',
+				'<widget></widget><paragraph>[b]ar</paragraph>',
+				keyCodes.arrowup,
+				'<widget></widget><paragraph>[b]ar</paragraph>'
+			);
+
+			test(
+				'should not move selection if there is no correct location - right arrow',
+				'<paragraph>foo</paragraph>[<widget></widget>]',
+				keyCodes.arrowright,
+				'<paragraph>foo</paragraph>[<widget></widget>]'
+			);
+
+			test(
+				'should not move selection if there is no correct location - down arrow',
+				'<paragraph>foo</paragraph>[<widget></widget>]',
+				keyCodes.arrowdown,
+				'<paragraph>foo</paragraph>[<widget></widget>]'
+			);
+
+			test(
+				'should not move selection if there is no correct location - left arrow',
+				'[<widget></widget>]<paragraph>foo</paragraph>',
+				keyCodes.arrowleft,
+				'[<widget></widget>]<paragraph>foo</paragraph>'
+			);
+
+			test(
+				'should not move selection if there is no correct location - up arrow',
+				'[<widget></widget>]<paragraph>foo</paragraph>',
+				keyCodes.arrowup,
+				'[<widget></widget>]<paragraph>foo</paragraph>'
+			);
+
+			it( 'should prevent default behaviour when there is no correct location - document end', () => {
+				const keydownHandler = sinon.spy();
+				const domEventDataMock = {
+					keyCode: keyCodes.arrowright,
+					preventDefault: sinon.spy(),
+				};
+				setModelData( doc, '<paragraph>foo</paragraph>[<widget></widget>]' );
+				viewDocument.on( 'keydown',  keydownHandler );
+
+				viewDocument.fire( 'keydown', domEventDataMock );
+
+				expect( getModelData( doc ) ).to.equal( '<paragraph>foo</paragraph>[<widget></widget>]' );
+				sinon.assert.calledOnce( domEventDataMock.preventDefault );
+				sinon.assert.notCalled( keydownHandler );
+			} );
+
+			it( 'should prevent default behaviour when there is no correct location - document start', () => {
+				const keydownHandler = sinon.spy();
+				const domEventDataMock = {
+					keyCode: keyCodes.arrowleft,
+					preventDefault: sinon.spy(),
+				};
+				setModelData( doc, '[<widget></widget>]<paragraph>foo</paragraph>' );
+				viewDocument.on( 'keydown',  keydownHandler );
+
+				viewDocument.fire( 'keydown', domEventDataMock );
+
+				expect( getModelData( doc ) ).to.equal( '[<widget></widget>]<paragraph>foo</paragraph>' );
+				sinon.assert.calledOnce( domEventDataMock.preventDefault );
+				sinon.assert.notCalled( keydownHandler );
+			} );
+
+			test(
+				'should move selection to object element - right arrow',
+				'<paragraph>foo[]</paragraph><widget></widget>',
+				keyCodes.arrowright,
+				'<paragraph>foo</paragraph>[<widget></widget>]'
+			);
+
+			test(
+				'should move selection to object element - down arrow',
+				'<paragraph>foo[]</paragraph><widget></widget>',
+				keyCodes.arrowdown,
+				'<paragraph>foo</paragraph>[<widget></widget>]'
+			);
+
+			test(
+				'should move selection to object element - left arrow',
+				'<widget></widget><paragraph>[]foo</paragraph>',
+				keyCodes.arrowleft,
+				'[<widget></widget>]<paragraph>foo</paragraph>'
+			);
+
+			test(
+				'should move selection to object element - up arrow',
+				'<widget></widget><paragraph>[]foo</paragraph>',
+				keyCodes.arrowup,
+				'[<widget></widget>]<paragraph>foo</paragraph>'
+			);
+
+			test(
+				'do nothing on non objects - right arrow',
+				'<paragraph>foo[]</paragraph><paragraph>bar</paragraph>',
+				keyCodes.arrowright,
+				'<paragraph>foo[]</paragraph><paragraph>bar</paragraph>'
+			);
+
+			test(
+				'do nothing on non objects - down arrow',
+				'<paragraph>foo[]</paragraph><paragraph>bar</paragraph>',
+				keyCodes.arrowdown,
+				'<paragraph>foo[]</paragraph><paragraph>bar</paragraph>'
+			);
+
+			test(
+				'do nothing on non objects - left arrow',
+				'<paragraph>foo</paragraph><paragraph>[]bar</paragraph>',
+				keyCodes.arrowleft,
+				'<paragraph>foo</paragraph><paragraph>[]bar</paragraph>'
+			);
+
+			test(
+				'do nothing on non objects - up arrow',
+				'<paragraph>foo</paragraph><paragraph>[]bar</paragraph>',
+				keyCodes.arrowup,
+				'<paragraph>foo</paragraph><paragraph>[]bar</paragraph>'
+			);
+
+			test(
+				'should work correctly with modifier key: right arrow + ctrl',
+				'[<widget></widget>]<paragraph>foo</paragraph>',
+				{ keyCode: keyCodes.arrowright, ctrlKey: true },
+				'<widget></widget><paragraph>[]foo</paragraph>'
+			);
+
+			test(
+				'should work correctly with modifier key: right arrow + alt',
+				'[<widget></widget>]<paragraph>foo</paragraph>',
+				{ keyCode: keyCodes.arrowright, altKey: true },
+				'<widget></widget><paragraph>[]foo</paragraph>'
+			);
+
+			test(
+				'should work correctly with modifier key: right arrow + shift',
+				'[<widget></widget>]<paragraph>foo</paragraph>',
+				{ keyCode: keyCodes.arrowright, shiftKey: true },
+				'<widget></widget><paragraph>[]foo</paragraph>'
+			);
+
+			test(
+				'should work correctly with modifier key: down arrow + ctrl',
+				'[<widget></widget>]<paragraph>foo</paragraph>',
+				{ keyCode: keyCodes.arrowdown, ctrlKey: true },
+				'<widget></widget><paragraph>[]foo</paragraph>'
+			);
+
+			test(
+				'should work correctly with modifier key: down arrow + alt',
+				'[<widget></widget>]<paragraph>foo</paragraph>',
+				{ keyCode: keyCodes.arrowdown, altKey: true },
+				'<widget></widget><paragraph>[]foo</paragraph>'
+			);
+
+			test(
+				'should work correctly with modifier key: down arrow + shift',
+				'[<widget></widget>]<paragraph>foo</paragraph>',
+				{ keyCode: keyCodes.arrowdown, shiftKey: true },
+				'<widget></widget><paragraph>[]foo</paragraph>'
+			);
+
+			test(
+				'should work correctly with modifier key: left arrow + ctrl',
+				'<paragraph>foo</paragraph>[<widget></widget>]',
+				{ keyCode: keyCodes.arrowleft, ctrlKey: true },
+				'<paragraph>foo[]</paragraph><widget></widget>'
+			);
+
+			test(
+				'should work correctly with modifier key: left arrow + alt',
+				'<paragraph>foo</paragraph>[<widget></widget>]',
+				{ keyCode: keyCodes.arrowleft, altKey: true },
+				'<paragraph>foo[]</paragraph><widget></widget>'
+			);
+
+			test(
+				'should work correctly with modifier key: left arrow + shift',
+				'<paragraph>foo</paragraph>[<widget></widget>]',
+				{ keyCode: keyCodes.arrowleft, shiftKey: true },
+				'<paragraph>foo[]</paragraph><widget></widget>'
+			);
+
+			test(
+				'should work correctly with modifier key: up arrow + ctrl',
+				'<paragraph>foo</paragraph>[<widget></widget>]',
+				{ keyCode: keyCodes.arrowup, ctrlKey: true },
+				'<paragraph>foo[]</paragraph><widget></widget>'
+			);
+
+			test(
+				'should work correctly with modifier key: up arrow + alt',
+				'<paragraph>foo</paragraph>[<widget></widget>]',
+				{ keyCode: keyCodes.arrowup, altKey: true },
+				'<paragraph>foo[]</paragraph><widget></widget>'
+			);
+
+			test(
+				'should work correctly with modifier key: up arrow + shift',
+				'<paragraph>foo</paragraph>[<widget></widget>]',
+				{ keyCode: keyCodes.arrowup, shiftKey: true },
+				'<paragraph>foo[]</paragraph><widget></widget>'
+			);
+
+			test(
+				'should do nothing if there is more than one selection in model',
+				'<paragraph>[foo]</paragraph><widget></widget><paragraph>[bar]</paragraph>',
+				keyCodes.arrowright,
+				'<paragraph>[foo]</paragraph><widget></widget><paragraph>[bar]</paragraph>'
+			);
+		} );
+
+		function test( name, data, keyCodeOrMock, expected ) {
+			it( name, () => {
+				const domEventDataMock = ( typeof keyCodeOrMock == 'object' ) ? keyCodeOrMock : {
+					keyCode: keyCodeOrMock
+				};
+
+				setModelData( doc, data );
+				viewDocument.fire( 'keydown', new DomEventData(
+					viewDocument,
+					{ target: document.createElement( 'div' ), preventDefault: () => {} },
+					domEventDataMock
+				) );
+
+				expect( getModelData( doc ) ).to.equal( expected );
+			} );
+		}
+	} );
+} );

+ 97 - 0
packages/ckeditor5-widget/tests/widgetengine.js

@@ -0,0 +1,97 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import WidgetEngine from '../src/widgetengine';
+import buildModelConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildmodelconverter';
+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 ViewContainer from '@ckeditor/ckeditor5-engine/src/view/containerelement';
+import ViewEditable from '@ckeditor/ckeditor5-engine/src/view/editableelement';
+import { toWidget } from '../src/utils';
+
+describe( 'WidgetEngine', () => {
+	let editor, document, viewDocument;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( {
+			plugins: [ WidgetEngine ]
+		} )
+			.then( newEditor => {
+				editor = newEditor;
+				document = editor.document;
+				viewDocument = editor.editing.view;
+				document.schema.registerItem( 'widget', '$block' );
+				document.schema.registerItem( 'editable' );
+				document.schema.allow( { name: '$inline', inside: 'editable' } );
+				document.schema.allow( { name: 'editable', inside: 'widget' } );
+				document.schema.allow( { name: 'editable', inside: '$root' } );
+
+				buildModelConverter().for( editor.editing.modelToView )
+					.fromElement( 'widget' )
+					.toElement( () => {
+						const element = toWidget( new ViewContainer( 'div' ), { label: 'element label' } );
+
+						return element;
+					} );
+
+				buildModelConverter().for( editor.editing.modelToView )
+					.fromElement( 'editable' )
+					.toElement( () => new ViewEditable( 'figcaption', { contenteditable: true } ) );
+			} );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( WidgetEngine ) ).to.be.instanceOf( WidgetEngine );
+	} );
+
+	it( 'should apply fake view selection if model selection is on widget element', () => {
+		setModelData( document, '[<widget>foo bar</widget>]' );
+
+		expect( getViewData( viewDocument ) ).to.equal(
+			'[<div class="ck-widget ck-widget_selected" contenteditable="false">foo bar</div>]'
+		);
+		expect( viewDocument.selection.isFake ).to.be.true;
+	} );
+
+	it( 'should use element\'s label to set fake selection if one is provided', () => {
+		setModelData( document, '[<widget>foo bar</widget>]' );
+
+		expect( viewDocument.selection.fakeSelectionLabel ).to.equal( 'element label' );
+	} );
+
+	it( 'fake selection should be empty if widget is not selected', () => {
+		setModelData( document, '<widget>foo bar</widget>' );
+
+		expect( viewDocument.selection.fakeSelectionLabel ).to.equal( '' );
+	} );
+
+	it( 'should toggle selected class', () => {
+		setModelData( document, '[<widget>foo</widget>]' );
+
+		expect( getViewData( viewDocument ) ).to.equal(
+			'[<div class="ck-widget ck-widget_selected" contenteditable="false">foo</div>]'
+		);
+
+		document.enqueueChanges( () => {
+			document.selection.collapseToStart();
+		} );
+
+		expect( getViewData( viewDocument ) ).to.equal(
+			'[]<div class="ck-widget" contenteditable="false">foo</div>'
+		);
+	} );
+
+	it( 'should do nothing when selection is placed in other editable', () => {
+		setModelData( document, '<widget><editable>foo bar</editable></widget><editable>[baz]</editable>' );
+
+		expect( getViewData( viewDocument ) ).to.equal(
+			'<div class="ck-widget" contenteditable="false">' +
+				'<figcaption contenteditable="true">foo bar</figcaption>' +
+			'</div>' +
+			'<figcaption contenteditable="true">{baz}</figcaption>'
+		);
+	} );
+} );

+ 41 - 0
packages/ckeditor5-widget/theme/theme.scss

@@ -0,0 +1,41 @@
+// Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+// For licensing, see LICENSE.md or http://ckeditor.com/license
+
+@import '../../ckeditor5-image/node_modules/@ckeditor/ckeditor5-theme-lark/theme/helpers/colors';
+@import '../../ckeditor5-image/node_modules/@ckeditor/ckeditor5-theme-lark/theme/helpers/shadow';
+@import '../../ckeditor5-image/node_modules/@ckeditor/ckeditor5-theme-lark/theme/helpers/states';
+@import '../../ckeditor5-image/node_modules/@ckeditor/ckeditor5-theme-lark/theme/helpers/spacing';
+
+@include ck-color-add( (
+	'widget-blurred': #ddd,
+	'widget-hover': #FFD25C
+) );
+
+$widget-outline-thickness: 3px;
+
+.ck-widget {
+	margin: ck-spacing() 0;
+	padding: 0;
+
+	&.ck-widget_selected, &.ck-widget_selected:hover {
+		outline: $widget-outline-thickness solid ck-color( 'focus' );
+	}
+
+	.ck-editor__editable.ck-blurred &.ck-widget_selected {
+		outline: $widget-outline-thickness solid ck-color( 'widget-blurred' );
+	}
+
+	&:hover {
+		outline: $widget-outline-thickness solid ck-color( 'widget-hover' );
+	}
+
+	.ck-editable {
+		// The `:focus` style is applied before `.ck-editable_focused` class is rendered in the view.
+		// These styles show a different border for a blink of an eye, so `:focus` need to have same styles applied.
+		&.ck-editable_focused, &:focus {
+			@include ck-focus-ring( 'outline' );
+			@include ck-box-shadow( $ck-inner-shadow );
+			background-color: ck-color( 'background' );
+		}
+	}
+}