浏览代码

Merge pull request #7 from ckeditor/t/1

Image feature initial implementation.
Piotrek Koszuliński 9 年之前
父节点
当前提交
34891820c1

+ 13 - 1
packages/ckeditor5-image/package.json

@@ -3,9 +3,21 @@
   "version": "0.0.1",
   "description": "Image feature for CKEditor 5.",
   "keywords": [],
-  "dependencies": {},
+  "dependencies": {
+	  "ckeditor5-core": "ckeditor/ckeditor5-core",
+	  "ckeditor5-engine": "ckeditor/ckeditor5-engine",
+	  "ckeditor5-ui": "ckeditor/ckeditor5-ui",
+	  "ckeditor5-ui-default": "ckeditor/ckeditor5-ui-default"
+  },
   "devDependencies": {
     "@ckeditor/ckeditor5-dev-lint": "^1.0.1",
+    "ckeditor5-clipboard": "ckeditor/ckeditor5-clipboard",
+    "ckeditor5-editor-classic": "ckeditor/ckeditor5-editor-classic",
+    "ckeditor5-enter": "ckeditor/ckeditor5-enter",
+    "ckeditor5-typing": "ckeditor/ckeditor5-typing",
+    "ckeditor5-paragraph": "ckeditor/ckeditor5-paragraph",
+    "ckeditor5-heading": "ckeditor/ckeditor5-heading",
+    "ckeditor5-undo": "ckeditor/ckeditor5-undo",
     "gulp": "^3.9.1",
     "guppy-pre-commit": "^0.4.0"
   },

+ 108 - 0
packages/ckeditor5-image/src/converters.js

@@ -0,0 +1,108 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ViewContainerElement from '../engine/view/containerelement.js';
+import ViewEmptyElement from '../engine/view/emptyelement.js';
+import ModelElement from '../engine/model/element.js';
+import { isImageWidget } from './utils.js';
+
+/**
+ * Returns function that converts image view representation:
+ *
+ *		<figure class="image"><img src="..." alt="..."></img></figure>
+ *
+ * to model representation:
+ *
+ *		<image src="..." alt="..."></image>
+ *
+ * @returns {Function}
+ */
+export function viewToModelImage() {
+	return ( evt, data, consumable, conversionApi ) => {
+		const viewFigureElement = data.input;
+
+		// *** Step 1: Validate conversion.
+		// Check if figure element can be consumed.
+		if ( !consumable.test( viewFigureElement, { name: true, class: 'image' } ) ) {
+			return;
+		}
+
+		// Check if image element can be converted in current context.
+		if ( !conversionApi.schema.check( { name: 'image', inside: data.context, attributes: 'src' } ) ) {
+			return;
+		}
+
+		// Check if img element is placed inside figure element and can be consumed with `src` attribute.
+		const viewImg = viewFigureElement.getChild( 0 );
+
+		if ( !viewImg || viewImg.name != 'img' || !consumable.test( viewImg, { name: true, attribute: 'src' } ) ) {
+			return;
+		}
+
+		// *** Step2: Convert to model.
+		consumable.consume( viewFigureElement, { name: true, class: 'image' } );
+		consumable.consume( viewImg, { name: true, attribute: 'src' } );
+
+		// Create model element.
+		const modelImage = new ModelElement( 'image', {
+			src: viewImg.getAttribute( 'src' )
+		} );
+
+		// Convert `alt` attribute if present.
+		if ( consumable.consume( viewImg, { attribute: [ 'alt' ] } ) ) {
+			modelImage.setAttribute( 'alt', viewImg.getAttribute( 'alt' ) );
+		}
+
+		data.output = modelImage;
+	};
+}
+
+/**
+ * Returns model to view selection converter. This converter is applied after default selection conversion is made.
+ * It creates fake view selection when {@link engine.view.Selection#getSelectedElement} returns instance of image widget.
+ *
+ * @param {Function} t {@link utils.Locale#t Locale#t function} used to translate default fake selection's label.
+ * @returns {Function}
+ */
+export function modelToViewSelection( t ) {
+	return ( evt, data, consumable, conversionApi ) => {
+		const viewSelection = conversionApi.viewSelection;
+		const selectedElement = viewSelection.getSelectedElement();
+
+		if ( !selectedElement || !isImageWidget( selectedElement ) ) {
+			return;
+		}
+
+		let fakeSelectionLabel = t( 'image widget' );
+		const imgElement = selectedElement.getChild( 0 );
+		const altText = imgElement.getAttribute( 'alt' );
+
+		if ( altText ) {
+			fakeSelectionLabel = `${ altText } ${ fakeSelectionLabel }`;
+		}
+
+		viewSelection.setFake( true, { label: fakeSelectionLabel } );
+	};
+}
+
+/**
+ * Converts model `image` element to view representation:
+ *
+ *		<figure class="image"><img src="..." alt="..."></img></figure>
+ *
+ * @param {engine.model.Element} modelElement
+ * @returns {engine.view.ContainerElement}
+ */
+export function modelToViewImage( modelElement ) {
+	const viewImg = new ViewEmptyElement( 'img', {
+		src: modelElement.getAttribute( 'src' )
+	} );
+
+	if ( modelElement.hasAttribute( 'alt' ) ) {
+		viewImg.setAttribute( 'alt', modelElement.getAttribute( 'alt' ) );
+	}
+
+	return new ViewContainerElement( 'figure', { class: 'image' }, viewImg );
+}

+ 25 - 0
packages/ckeditor5-image/src/image.js

@@ -0,0 +1,25 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Plugin from '../core/plugin.js';
+import ImageEngine from './imageengine.js';
+import Widget from './widget/widget.js';
+
+/**
+ * The image plugin.
+ *
+ * Uses {@link image.ImageEngine}.
+ *
+ * @memberOf image
+ * @extends core.Plugin
+ */
+export default class Image extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ImageEngine, Widget ];
+	}
+}

+ 60 - 0
packages/ckeditor5-image/src/imageengine.js

@@ -0,0 +1,60 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Plugin from '../core/plugin.js';
+import buildModelConverter from '../engine/conversion/buildmodelconverter.js';
+import WidgetEngine from './widget/widgetengine.js';
+import { modelToViewImage, viewToModelImage, modelToViewSelection } from './converters.js';
+import { toImageWidget } from './utils.js';
+
+/**
+ * The image engine plugin.
+ * Registers `image` as a block element in document's schema and allows it to have two attributes: `src` and `alt`.
+ * Registers converters for editing and data pipelines.
+ *
+ * @memberof image
+ * @extends core.Plugin.
+ */
+export default class ImageEngine extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ WidgetEngine ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const doc = editor.document;
+		const schema = doc.schema;
+		const data = editor.data;
+		const editing = editor.editing;
+
+		// Configure schema.
+		schema.registerItem( 'image' );
+		schema.requireAttributes( 'image', [ 'src' ] );
+		schema.allow( { name: 'image', attributes: [ 'alt', 'src' ], inside: '$root' } );
+		schema.objects.add( 'image' );
+
+		// Build converter from model to view for data pipeline.
+		buildModelConverter().for( data.modelToView )
+			.fromElement( 'image' )
+			.toElement( ( data ) => modelToViewImage( data.item ) );
+
+		// Build converter from model to view for editing pipeline.
+		buildModelConverter().for( editing.modelToView )
+			.fromElement( 'image' )
+			.toElement( ( data ) => toImageWidget( modelToViewImage( data.item ) ) );
+
+		// Converter for figure element from view to model.
+		data.viewToModel.on( 'element:figure', viewToModelImage() );
+
+		// Creates fake selection label if selection is placed around image widget.
+		editing.modelToView.on( 'selection', modelToViewSelection( editor.t ), { priority: 'lowest' } );
+	}
+}

+ 32 - 0
packages/ckeditor5-image/src/utils.js

@@ -0,0 +1,32 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import { widgetize, isWidget } from './widget/utils.js';
+
+const imageSymbol = Symbol( 'isImage' );
+
+/**
+ * Converts given {@link engine.view.Element} to image widget:
+ * * adds {@link engine.view.Element#addCustomProperty custom property} allowing to recognize image widget element,
+ * * calls {@link image.widget.utils.widgetize widgetize}.
+ *
+ * @param {engine.view.Element} viewElement
+ * @returns {engine.view.Element}
+ */
+export function toImageWidget( viewElement ) {
+	viewElement.setCustomProperty( imageSymbol, true );
+
+	return widgetize( viewElement );
+}
+
+/**
+ * Checks if given view element is an image widget.
+ *
+ * @param {engine.view.Element} viewElement
+ * @returns {Boolean}
+ */
+export function isImageWidget( viewElement ) {
+	return !!viewElement.getCustomProperty( imageSymbol ) && isWidget( viewElement );
+}

+ 57 - 0
packages/ckeditor5-image/src/widget/utils.js

@@ -0,0 +1,57 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+const widgetSymbol = Symbol( 'isWidget' );
+
+/**
+ * CSS classes added to each widget element.
+ *
+ * @member {String} image.widget.utils.WIDGET_CLASS_NAME
+ */
+export const WIDGET_CLASS_NAME = 'ck-widget';
+
+/**
+ * CSS classes added to currently selected widget element.
+ *
+ * @member {String} image.widget.utils.WIDGET_SELECTED_CLASS_NAME
+ */
+export const WIDGET_SELECTED_CLASS_NAME = 'ck-widget_selected';
+
+/**
+ * Returns `true` if given {@link engine.view.Element} is a widget.
+ *
+ * @method image.widget.utils.isWidget
+ * @param {engine.view.Element} element
+ * @returns {Boolean}
+ */
+export function isWidget( element ) {
+	return !!element.getCustomProperty( widgetSymbol );
+}
+
+/**
+ * "Widgetizes" given {@link engine.view.Element}:
+ * * sets `contenteditable` attribue to `true`,
+ * * adds custom `getFillerOffset` method returning `null`,
+ * * adds `ck-widget` CSS class,
+ * * adds custom property allowing to recognize widget elements by using {@link image.widget.utils.isWidget}.
+ *
+ * @param {engine.view.Element} element
+ * @returns {engine.view.Element} Returns same element.
+ */
+export function widgetize( element ) {
+	element.setAttribute( 'contenteditable', false );
+	element.getFillerOffset = getFillerOffset;
+	element.addClass( WIDGET_CLASS_NAME );
+	element.setCustomProperty( widgetSymbol, true );
+
+	return element;
+}
+
+// Default filler offset function applied to all widget elements.
+//
+// @returns {null}
+function getFillerOffset() {
+	return null;
+}

+ 75 - 0
packages/ckeditor5-image/src/widget/widget.js

@@ -0,0 +1,75 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Plugin from '../../core/plugin.js';
+import WidgetEngine from './widgetengine.js';
+import MouseObserver from '../../engine/view/observer/mouseobserver.js';
+import ModelRange from '../../engine/model/range.js';
+import { isWidget } from './utils.js';
+
+/**
+ * The widget plugin.
+ * Adds default {@link engine.view.Document#mousedown mousedown} handling on widget elements.
+ *
+ * @memberOf image.widget
+ * @extends core.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 ) );
+	}
+
+	/**
+	 * Handles {@link engine.view.Document#mousedown mousedown} events on widget elements.
+	 *
+	 * @param {utils.EventInfo} eventInfo
+	 * @param {envine.view.observer.DomEventData} domEventData
+	 * @private
+	 */
+	_onMousedown( eventInfo, domEventData ) {
+		let widgetElement = domEventData.target;
+		const editor = this.editor;
+		const viewDocument = editor.editing.view;
+
+		// If target is not a widget element - check if one of the ancestors is.
+		if ( !isWidget( widgetElement ) ) {
+			widgetElement = widgetElement.findAncestor( element => isWidget( element ) );
+
+			if ( !widgetElement ) {
+				return;
+			}
+		}
+
+		domEventData.preventDefault();
+
+		// Focus editor if is not focused already.
+		if ( !viewDocument.isFocused ) {
+			viewDocument.focus();
+		}
+
+		// Create model selection over widget.
+		const modelDocument = editor.document;
+		const modelElement = editor.editing.mapper.toModelElement( widgetElement );
+		const modelRange = ModelRange.createOn( modelElement );
+
+		modelDocument.enqueueChanges( ( ) => {
+			modelDocument.selection.setRanges( [ modelRange ] );
+		} );
+	}
+}

+ 45 - 0
packages/ckeditor5-image/src/widget/widgetengine.js

@@ -0,0 +1,45 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Plugin from '../../core/plugin.js';
+import { WIDGET_SELECTED_CLASS_NAME, isWidget } from './utils.js';
+
+/**
+ * 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.
+ *
+ * @memberOf image.widget
+ * @extends core.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;
+			const selectedElement = viewSelection.getSelectedElement();
+
+			if ( !selectedElement || !isWidget( selectedElement ) ) {
+				return;
+			}
+
+			viewSelection.setFake( true );
+			selectedElement.addClass( WIDGET_SELECTED_CLASS_NAME );
+			previouslySelected = selectedElement;
+		}, { priority: 'low' } );
+	}
+}

+ 39 - 0
packages/ckeditor5-image/tests/image.js

@@ -0,0 +1,39 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global window */
+
+import ClassicTestEditor from 'tests/core/_utils/classictesteditor.js';
+import Image from 'ckeditor5/image/image.js';
+import ImageEngine from 'ckeditor5/image/imageengine.js';
+import Widget from 'ckeditor5/image/widget/widget.js';
+
+describe( 'Image', () => {
+	let editor;
+
+	beforeEach( () => {
+		const editorElement = window.document.createElement( 'div' );
+		window.document.body.appendChild( editorElement );
+
+		return ClassicTestEditor.create( editorElement, {
+			plugins: [ Image ]
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+		} );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( Image ) ).to.instanceOf( Image );
+	} );
+
+	it( 'should load ImageEngine feature', () => {
+		expect( editor.plugins.get( ImageEngine ) ).to.instanceOf( ImageEngine );
+	} );
+
+	it( 'should load Widget feature', () => {
+		expect( editor.plugins.get( Widget ) ).to.instanceOf( Widget );
+	} );
+} );

+ 214 - 0
packages/ckeditor5-image/tests/imageengine.js

@@ -0,0 +1,214 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from 'tests/core/_utils/virtualtesteditor.js';
+import ImageEngine from 'ckeditor5/image/imageengine.js';
+import { getData as getModelData, setData as setModelData } from 'ckeditor5/engine/dev-utils/model.js';
+import { getData as getViewData } from 'ckeditor5/engine/dev-utils/view.js';
+import buildViewConverter from 'ckeditor5/engine/conversion/buildviewconverter.js';
+import buildModelConverter from 'ckeditor5/engine/conversion/buildmodelconverter.js';
+import { isImageWidget } from 'ckeditor5/image/utils.js';
+import ModelRange from 'ckeditor5/engine/model/range.js';
+
+describe( 'ImageEngine', () => {
+	let editor, document, viewDocument;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( {
+			plugins: [ ImageEngine ]
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+			document = editor.document;
+			viewDocument = editor.editing.view;
+		} );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( ImageEngine ) ).to.be.instanceOf( ImageEngine );
+	} );
+
+	it( 'should set proper schema rules', () => {
+		expect( document.schema.check( { name: 'image', attributes: [ 'src', 'alt' ], inside: '$root' } ) ).to.be.true;
+		expect( document.schema.objects.has( 'image' ) ).to.be.true;
+	} );
+
+	describe( 'conversion in data pipeline', () => {
+		describe( 'model to view', () => {
+			it( 'should convert', () => {
+				setModelData( document, '<image src="foo.png" alt="alt text"></image>' );
+
+				expect( editor.getData() ).to.equal( '<figure class="image"><img src="foo.png" alt="alt text"></figure>' );
+			} );
+
+			it( 'should convert without alt attribute', () => {
+				setModelData( document, '<image src="foo.png"></image>' );
+
+				expect( editor.getData() ).to.equal( '<figure class="image"><img src="foo.png"></figure>' );
+			} );
+		} );
+
+		describe( 'view to model', () => {
+			it( 'should convert image figure', () => {
+				editor.setData( '<figure class="image"><img src="foo.png" alt="alt text" /></figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '<image alt="alt text" src="foo.png"></image>' );
+			} );
+
+			it( 'should not convert if there is no image class', () => {
+				editor.setData( '<figure><img src="foo.png" alt="alt text" /></figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+
+			it( 'should not convert if there is no img inside #1', () => {
+				editor.setData( '<figure class="image"></figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+
+			it( 'should not convert if there is no img inside #2', () => {
+				editor.setData( '<figure class="image">test</figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+
+			it( 'should convert without alt attribute', () => {
+				editor.setData( '<figure class="image"><img src="foo.png" /></figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '<image src="foo.png"></image>' );
+			} );
+
+			it( 'should not convert without src attribute', () => {
+				editor.setData( '<figure class="image"><img alt="alt text" /></figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+
+			it( 'should not convert in wrong context', () => {
+				const data = editor.data;
+				const editing = editor.editing;
+
+				document.schema.registerItem( 'div', '$block' );
+				document.schema.disallow( { name: 'image', inside: 'div', attributes: [ 'src' ] } );
+
+				buildModelConverter().for( data.modelToView, editing.modelToView ).fromElement( 'div' ).toElement( 'div' );
+				buildViewConverter().for( data.viewToModel ).fromElement( 'div' ).toElement( 'div' );
+
+				editor.setData( '<div><figure class="image"><img src="foo.png" alt="alt text" /></figure></div>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '<div></div>' );
+			} );
+
+			it( 'should not convert if img is already consumed', () => {
+				editor.data.viewToModel.on( 'element:figure', ( evt, data, consumable ) => {
+					const img = data.input.getChild( 0 );
+					consumable.consume( img, { name: true } );
+				}, { priority: 'high' } );
+
+				editor.setData( '<figure class="image"><img src="foo.png" alt="alt text" /></figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+
+			it( 'should not convert if figure is already consumed', () => {
+				editor.data.viewToModel.on( 'element:figure', ( evt, data, consumable ) => {
+					const figure = data.input;
+					consumable.consume( figure, { name: true, class: 'image' } );
+				}, { priority: 'high' } );
+
+				editor.setData( '<figure class="image"><img src="foo.png" alt="alt text" /></figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '' );
+			} );
+		} );
+	} );
+
+	describe( 'conversion in editing pipeline', () => {
+		describe( 'model to view', () => {
+			it( 'should convert', () => {
+				setModelData( document, '<image src="foo.png" alt="alt text"></image>' );
+
+				expect( getViewData( viewDocument, { withoutSelection: true } ) )
+					.to.equal( '<figure class="image ck-widget" contenteditable="false"><img alt="alt text" src="foo.png"></img></figure>' );
+			} );
+
+			it( 'converted element should be widgetized', () => {
+				setModelData( document, '<image src="foo.png" alt="alt text"></image>' );
+				const figure = viewDocument.getRoot().getChild( 0 );
+
+				expect( figure.name ).to.equal( 'figure' );
+				expect( isImageWidget( figure ) ).to.be.true;
+			} );
+		} );
+	} );
+
+	describe( 'selection conversion', () => {
+		it( 'should convert selection', () => {
+			setModelData( document, '[<image alt="alt text" src="foo.png"></image>]' );
+
+			expect( getViewData( viewDocument ) ).to.equal(
+				'[<figure class="image ck-widget ck-widget_selected" contenteditable="false">' +
+					'<img alt="alt text" src="foo.png"></img>' +
+				'</figure>]'
+			);
+
+			expect( viewDocument.selection.isFake ).to.be.true;
+			expect( viewDocument.selection.fakeSelectionLabel ).to.equal( 'alt text image widget' );
+		} );
+
+		it( 'should create proper fake selection label when alt attribute is empty', () => {
+			setModelData( document, '[<image src="foo.png" alt=""></image>]' );
+
+			expect( getViewData( viewDocument ) ).to.equal(
+				'[<figure class="image ck-widget ck-widget_selected" contenteditable="false">' +
+					'<img alt="" src="foo.png"></img>' +
+				'</figure>]'
+			);
+
+			expect( viewDocument.selection.isFake ).to.be.true;
+			expect( viewDocument.selection.fakeSelectionLabel ).to.equal( 'image widget' );
+		} );
+
+		it( 'should remove selected class from previously selected element', () => {
+			setModelData( document,
+				'[<image src="foo.png" alt="alt text"></image>]' +
+				'<image src="foo.png" alt="alt text"></image>'
+			);
+
+			expect( getViewData( viewDocument ) ).to.equal(
+				'[<figure class="image ck-widget ck-widget_selected" contenteditable="false">' +
+					'<img alt="alt text" src="foo.png"></img>' +
+				'</figure>]' +
+				'<figure class="image ck-widget" contenteditable="false">' +
+					'<img alt="alt text" src="foo.png"></img>' +
+				'</figure>'
+			);
+
+			document.enqueueChanges( () => {
+				const secondImage = document.getRoot().getChild( 1 );
+				document.selection.setRanges( [ ModelRange.createOn( secondImage ) ] );
+			} );
+
+			expect( getViewData( viewDocument ) ).to.equal(
+				'<figure class="image ck-widget" contenteditable="false">' +
+					'<img alt="alt text" src="foo.png"></img>' +
+				'</figure>' +
+				'[<figure class="image ck-widget ck-widget_selected" contenteditable="false">' +
+					'<img alt="alt text" src="foo.png"></img>' +
+				'</figure>]'
+			);
+		} );
+	} );
+} );

+ 14 - 0
packages/ckeditor5-image/tests/manual/image.html

@@ -0,0 +1,14 @@
+<head>
+	<link rel="stylesheet" href="/theme/ckeditor.css">
+</head>
+
+<div id="editor">
+	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus consequat placerat. Vestibulum id tellus et mauris sagittis tincidunt quis id mauris. Curabitur consectetur lectus sit amet tellus mattis, non lobortis leo interdum. </p>
+	<figure class="image">
+		<img src="logo.png" alt="CKEditor logo" />
+	</figure>
+	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus consequat placerat. Vestibulum id tellus et mauris sagittis tincidunt quis id mauris. Curabitur consectetur lectus sit amet tellus mattis, non lobortis leo interdum. </p>
+	<figure class="image">
+		<img src="logo.png" alt="" />
+	</figure>
+</div>

+ 26 - 0
packages/ckeditor5-image/tests/manual/image.js

@@ -0,0 +1,26 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document, console, window */
+
+import ClassicEditor from 'ckeditor5/editor-classic/classic.js';
+import EnterPlugin from 'ckeditor5/enter/enter.js';
+import TypingPlugin from 'ckeditor5/typing/typing.js';
+import ParagraphPlugin from 'ckeditor5/paragraph/paragraph.js';
+import HeadingPlugin from 'ckeditor5/heading/heading.js';
+import ImagePlugin from 'ckeditor5/image/image.js';
+import UndoPlugin from 'ckeditor5/undo/undo.js';
+import ClipboardPlugin from 'ckeditor5/clipboard/clipboard.js';
+
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	plugins: [ EnterPlugin, TypingPlugin, ParagraphPlugin, HeadingPlugin, ImagePlugin, UndoPlugin, ClipboardPlugin ],
+	toolbar: [ 'headings', 'undo', 'redo' ]
+} )
+.then( editor => {
+	window.editor = editor;
+} )
+.catch( err => {
+	console.error( err.stack );
+} );

+ 5 - 0
packages/ckeditor5-image/tests/manual/image.md

@@ -0,0 +1,5 @@
+## Image feature
+
+* Two images with CKEditor logo should be loaded.
+* Hovering over image should apply yellow outline.
+* Clicking on image should apply blue outline which should not change when hovering over.

二进制
packages/ckeditor5-image/tests/manual/logo.png


+ 33 - 0
packages/ckeditor5-image/tests/utils.js

@@ -0,0 +1,33 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ViewElement from 'ckeditor5/engine/view/element.js';
+import { toImageWidget, isImageWidget } from 'ckeditor5/image/utils.js';
+import { isWidget } from 'ckeditor5/image/widget/utils.js';
+
+describe( 'image widget utils', () => {
+	let element;
+
+	beforeEach( () => {
+		element = new ViewElement( 'div' );
+		toImageWidget( element );
+	} );
+
+	describe( 'toImageWidget()', () => {
+		it( 'should be widgetized', () => {
+			expect( isWidget( element ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'isImageWidget()', () => {
+		it( 'should return true for elements marked with toImageWidget()', () => {
+			expect( isImageWidget( element ) ).to.be.true;
+		} );
+
+		it( 'should return false for non-widgetized elements', () => {
+			expect( isImageWidget( new ViewElement( 'p' ) ) ).to.be.false;
+		} );
+	} );
+} );

+ 41 - 0
packages/ckeditor5-image/tests/widget/utils.js

@@ -0,0 +1,41 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ViewElement from 'ckeditor5/engine/view/element.js';
+import { widgetize, isWidget, WIDGET_CLASS_NAME } from 'ckeditor5/image/widget/utils.js';
+
+describe( 'widget utils', () => {
+	let element;
+
+	beforeEach( () => {
+		element = new ViewElement( 'div' );
+		widgetize( element );
+	} );
+
+	describe( 'widgetize()', () => {
+		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;
+		} );
+	} );
+
+	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;
+		} );
+	} );
+} );

+ 113 - 0
packages/ckeditor5-image/tests/widget/widget.js

@@ -0,0 +1,113 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from 'tests/core/_utils/virtualtesteditor.js';
+import Widget from 'ckeditor5/image/widget/widget.js';
+import MouseObserver from 'ckeditor5/engine/view/observer/mouseobserver.js';
+import buildModelConverter from 'ckeditor5/engine/conversion/buildmodelconverter.js';
+import { widgetize } from 'ckeditor5/image/widget/utils.js';
+import ViewContainer from 'ckeditor5/engine/view/containerelement.js';
+import AttributeContainer from 'ckeditor5/engine/view/attributeelement.js';
+import { setData as setModelData, getData as getModelData } from 'ckeditor5/engine/dev-utils/model.js';
+
+describe( 'Widget', () => {
+	let editor, document, viewDocument;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( {
+			plugins: [ Widget ]
+		} )
+			.then( newEditor => {
+				editor = newEditor;
+				document = editor.document;
+				viewDocument = editor.editing.view;
+
+				document.schema.registerItem( 'widget', '$block' );
+				document.schema.registerItem( 'paragraph', '$block' );
+
+				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 widgetize( div );
+					} );
+			} );
+	} );
+
+	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( document, '[]<widget></widget>' );
+		const viewDiv = viewDocument.getRoot().getChild( 0 );
+		const domEventDataMock = {
+			target: viewDiv,
+			preventDefault: sinon.spy()
+		};
+
+		viewDocument.fire( 'mousedown', domEventDataMock );
+
+		expect( getModelData( document ) ).to.equal( '[<widget></widget>]' );
+		sinon.assert.calledOnce( domEventDataMock.preventDefault );
+	} );
+
+	it( 'should create selection when clicked in nested element', () => {
+		setModelData( document, '[]<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( document ) ).to.equal( '[<widget></widget>]' );
+		sinon.assert.calledOnce( domEventDataMock.preventDefault );
+	} );
+
+	it( 'should do nothing if clicked in non-widget element', () => {
+		setModelData( document, '<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( document ) ).to.equal( '<paragraph>[]foo bar</paragraph><widget></widget>' );
+		sinon.assert.notCalled( domEventDataMock.preventDefault );
+	} );
+
+	it( 'should not focus editable if already is focused', () => {
+		setModelData( document, '<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( document ) ).to.equal( '[<widget></widget>]' );
+	} );
+} );

+ 61 - 0
packages/ckeditor5-image/tests/widget/widgetengine.js

@@ -0,0 +1,61 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from 'tests/core/_utils/virtualtesteditor.js';
+import WidgetEngine from 'ckeditor5/image/widget/widgetengine.js';
+import buildModelConverter from 'ckeditor5/engine/conversion/buildmodelconverter.js';
+import { setData as setModelData } from 'ckeditor5/engine/dev-utils/model.js';
+import { getData as getViewData } from 'ckeditor5/engine/dev-utils/view.js';
+import ViewContainer from 'ckeditor5/engine/view/containerelement.js';
+import { widgetize } from 'ckeditor5/image/widget/utils.js';
+
+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' );
+
+				buildModelConverter().for( editor.editing.modelToView )
+					.fromElement( 'widget' )
+					.toElement( () => widgetize( new ViewContainer( 'div' ) ) );
+			} );
+	} );
+
+	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 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>'
+		);
+	} );
+} );

+ 22 - 0
packages/ckeditor5-image/theme/theme.scss

@@ -0,0 +1,22 @@
+// Common styles applied to all widgets.
+.ck-widget {
+	margin: 10px 0;
+	padding: 0;
+
+	&.ck-widget_selected, &.ck-widget_selected:hover {
+		outline: 2px solid #ace;
+	}
+
+	.ck-editor__editable.ck-blurred &.ck-widget_selected {
+		outline: 2px solid #ddd;
+	}
+
+	&:hover {
+		outline: 2px solid yellow;
+	}
+}
+
+// Image widget's styles.
+.ck-widget.image {
+	text-align: center;
+}