8
0
Quellcode durchsuchen

Merge pull request #48 from ckeditor/t/28

Feature: Introduced image captions support. Closes #28.
Piotrek Koszuliński vor 9 Jahren
Ursprung
Commit
f94aececf9

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

@@ -8,6 +8,8 @@
  */
 
 import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
+import ModelPosition from '@ckeditor/ckeditor5-engine/src/model/position';
+import modelWriter from '@ckeditor/ckeditor5-engine/src/model/writer';
 import { isImageWidget } from './utils';
 
 /**
@@ -57,6 +59,14 @@ export function viewToModelImage() {
 			modelImage.setAttribute( 'alt', viewImg.getAttribute( 'alt' ) );
 		}
 
+		// Convert children of converted view element and append them to `modelImage`.
+		// TODO https://github.com/ckeditor/ckeditor5-engine/issues/736.
+		data.context.push( modelImage );
+		const modelChildren = conversionApi.convertChildren( viewFigureElement, consumable, data );
+		const insertPosition = ModelPosition.createAt( modelImage, 'end' );
+		modelWriter.insert( insertPosition, modelChildren );
+		data.context.pop();
+
 		data.output = modelImage;
 	};
 }

+ 26 - 0
packages/ckeditor5-image/src/imagecaption/imagecaption.js

@@ -0,0 +1,26 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/imagecaption/imagecaption
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ImageCaptionEngine from './imagecaptionengine';
+import '../../theme/imagecaption/theme.scss';
+
+/**
+ * The image caption plugin.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageCaption extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ImageCaptionEngine ];
+	}
+}

+ 225 - 0
packages/ckeditor5-image/src/imagecaption/imagecaptionengine.js

@@ -0,0 +1,225 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/imagecaption/imagecaptionengine
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ModelTreeWalker from '@ckeditor/ckeditor5-engine/src/model/treewalker';
+import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
+import ViewContainerElement from '@ckeditor/ckeditor5-engine/src/view/containerelement';
+import ViewElement from '@ckeditor/ckeditor5-engine/src/view/element';
+import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position';
+import ViewRange from '@ckeditor/ckeditor5-engine/src/view/range';
+import viewWriter from '@ckeditor/ckeditor5-engine/src/view/writer';
+import ModelPosition from '@ckeditor/ckeditor5-engine/src/model/position';
+import buildViewConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildviewconverter';
+import ViewMatcher from '@ckeditor/ckeditor5-engine/src/view/matcher';
+import { isImage, isImageWidget } from '../utils';
+import { captionElementCreator, isCaption, getCaptionFromImage } from './utils';
+
+/**
+ * The image caption engine plugin.
+ *
+ * Registers proper converters. Takes care of adding caption element if image without it is inserted to model document.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageCaptionEngine extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const document = editor.document;
+		const viewDocument = editor.editing.view;
+		const schema = document.schema;
+		const data = editor.data;
+		const editing = editor.editing;
+
+		/**
+		 * Last selected caption editable.
+		 * It is used for hiding editable when is empty and image widget is no longer selected.
+		 *
+		 * @member {module:image/imagecaption/imagecaptionengine~ImageCaptionEngine} #_lastSelectedEditable
+		 */
+
+		// Schema configuration.
+		schema.registerItem( 'caption' );
+		schema.allow( { name: '$inline', inside: 'caption' } );
+		schema.allow( { name: 'caption', inside: 'image' } );
+		schema.limits.add( 'caption' );
+
+		// Add caption element to each image inserted without it.
+		document.on( 'change', insertMissingCaptionElement );
+
+		// View to model converter for data pipeline.
+		const matcher = new ViewMatcher( ( element ) => {
+			const parent = element.parent;
+
+			// Convert only captions for images.
+			if ( element.name == 'figcaption' && parent && parent.name == 'figure' && parent.hasClass( 'image' ) ) {
+				return { name: true };
+			}
+
+			return null;
+		} );
+
+		buildViewConverter()
+			.for( data.viewToModel )
+			.from( matcher )
+			.toElement( 'caption' );
+
+		// Model to view converter for data pipeline.
+		data.modelToView.on(
+			'insert:caption',
+			captionModelToView( new ViewContainerElement( 'figcaption' ) )
+		);
+
+		// Model to view converter for editing pipeline.
+		editing.modelToView.on(
+			'insert:caption',
+			captionModelToView( captionElementCreator( viewDocument ) )
+		);
+
+		// Adding / removing caption element when there is no text in the model.
+		const selection = viewDocument.selection;
+
+		// Update view before each rendering.
+		this.listenTo( viewDocument, 'render', () => {
+			// Check if there is an empty caption view element to remove.
+			this._removeEmptyCaption();
+
+			// Check if image widget is selected and caption view element needs to be added.
+			this._addCaption();
+
+			// If selection is currently inside caption editable - store it to hide when empty.
+			const editableElement = selection.editableElement;
+
+			if ( editableElement && isCaption( selection.editableElement ) ) {
+				this._lastSelectedEditable = selection.editableElement;
+			}
+		}, { priority: 'high' } );
+	}
+
+	/**
+	 * Checks if there is an empty caption element to remove from view.
+	 *
+	 * @private
+	 */
+	_removeEmptyCaption() {
+		const viewSelection = this.editor.editing.view.selection;
+		const viewCaptionElement = this._lastSelectedEditable;
+
+		// No caption to hide.
+		if ( !viewCaptionElement ) {
+			return;
+		}
+
+		// If selection is placed inside caption - do not remove it.
+		if ( viewSelection.editableElement === viewCaptionElement ) {
+			return;
+		}
+
+		// Do not remove caption if selection is placed on image that contains that caption.
+		const selectedElement = viewSelection.getSelectedElement();
+
+		if ( selectedElement && isImageWidget( selectedElement ) ) {
+			const viewImage = viewCaptionElement.findAncestor( element => element == selectedElement );
+
+			if ( viewImage ) {
+				return;
+			}
+		}
+
+		// Remove image caption if its empty.
+		if ( viewCaptionElement.childCount === 0 ) {
+			const mapper = this.editor.editing.mapper;
+			viewWriter.remove( ViewRange.createOn( viewCaptionElement ) );
+			mapper.unbindViewElement( viewCaptionElement );
+		}
+	}
+
+	/**
+	 * Checks if selected image needs a new caption element inside.
+	 *
+	 * @private
+	 */
+	_addCaption() {
+		const editing = this.editor.editing;
+		const selection = editing.view.selection;
+		const imageFigure = selection.getSelectedElement();
+		const mapper = editing.mapper;
+		const editableCreator = captionElementCreator( editing.view );
+
+		if ( imageFigure && isImageWidget( imageFigure ) ) {
+			const modelImage = mapper.toModelElement( imageFigure );
+			const modelCaption = getCaptionFromImage( modelImage );
+			let viewCaption =  mapper.toViewElement( modelCaption );
+
+			if ( !viewCaption ) {
+				viewCaption = editableCreator();
+
+				const viewPosition = ViewPosition.createAt( imageFigure, 'end' );
+				mapper.bindElements( modelCaption, viewCaption );
+				viewWriter.insert( viewPosition, viewCaption );
+			}
+
+			this._lastSelectedEditable = viewCaption;
+		}
+	}
+}
+
+// Checks whether data inserted to the model document have image element that has no caption element inside it.
+// If there is none - adds it to the image element.
+//
+// @private
+function insertMissingCaptionElement( evt, changeType, data, batch ) {
+	if ( changeType !== 'insert' ) {
+		return;
+	}
+
+	const walker = new ModelTreeWalker( {
+		boundaries: data.range,
+		ignoreElementEnd: true
+	} );
+
+	for ( let value of walker ) {
+		const item = value.item;
+
+		if ( value.type == 'elementStart' && isImage( item ) && !getCaptionFromImage( item ) ) {
+			batch.document.enqueueChanges( () => {
+				batch.insert( ModelPosition.createAt( item, 'end' ), new ModelElement( 'caption' ) );
+			} );
+		}
+	}
+}
+
+// Creates a converter that converts image caption model element to view element.
+//
+// @private
+// @param {Function|module:engine/view/element~Element} elementCreator
+// @return {Function}
+function captionModelToView( elementCreator ) {
+	return ( evt, data, consumable, conversionApi ) => {
+		const captionElement = data.item;
+
+		if ( isImage( captionElement.parent ) && ( captionElement.childCount > 0 ) ) {
+			if ( !consumable.consume( data.item, 'insert' ) ) {
+				return;
+			}
+
+			const imageFigure = conversionApi.mapper.toViewElement( data.range.start.parent );
+			const viewElement = ( elementCreator instanceof ViewElement ) ?
+				elementCreator.clone( true ) :
+				elementCreator( data, consumable, conversionApi );
+
+			const viewPosition = ViewPosition.createAt( imageFigure, 'end' );
+			conversionApi.mapper.bindElements( data.item, viewElement );
+			viewWriter.insert( viewPosition, viewElement );
+		}
+	};
+}

+ 63 - 0
packages/ckeditor5-image/src/imagecaption/utils.js

@@ -0,0 +1,63 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/imagecaption/utils
+ */
+
+import ViewEditableElement from '@ckeditor/ckeditor5-engine/src/view/editableelement';
+import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
+
+const captionSymbol = Symbol( 'imageCaption' );
+
+/**
+ * Returns a function that creates caption editable element for the given {@link module:engine/view/document~Document}.
+ *
+ * @param {module:engine/view/document~Document} viewDocument
+ * @return {Function}
+ */
+export function captionElementCreator( viewDocument ) {
+	return () => {
+		const editable = new ViewEditableElement( 'figcaption', { contenteditable: true } );
+		editable.document = viewDocument;
+		editable.setCustomProperty( captionSymbol, true );
+
+		editable.on( 'change:isFocused', ( evt, property, is ) => {
+			if ( is ) {
+				editable.addClass( 'focused' );
+			} else {
+				editable.removeClass( 'focused' );
+			}
+		} );
+
+		return editable;
+	};
+}
+
+/**
+ * Returns `true` if given view element is image's caption editable.
+ *
+ * @param {module:engine/view/element~Element} viewElement
+ * @return {Boolean}
+ */
+export function isCaption( viewElement ) {
+	return !!viewElement.getCustomProperty( captionSymbol );
+}
+
+/**
+ * Returns caption's model element from given image element. Returns `null` if no caption is found.
+ *
+ * @param {module:engine/model/element~Element} imageModelElement
+ * @return {module:engine/model/element~Element|null}
+ */
+export function getCaptionFromImage( imageModelElement ) {
+	for ( let node of imageModelElement.getChildren() ) {
+		if ( node instanceof ModelElement && node.name == 'caption' ) {
+			return node;
+		}
+	}
+
+	return null;
+}

+ 28 - 5
packages/ckeditor5-image/src/widget/widget.js

@@ -13,6 +13,8 @@ import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobs
 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';
 
@@ -52,15 +54,20 @@ export default class Widget extends Plugin {
 	 * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData
 	 */
 	_onMousedown( eventInfo, domEventData ) {
-		let widgetElement = domEventData.target;
 		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( widgetElement ) ) {
-			widgetElement = widgetElement.findAncestor( element => isWidget( element ) );
+		if ( !isWidget( element ) ) {
+			element = element.findAncestor( isWidget );
 
-			if ( !widgetElement ) {
+			if ( !element ) {
 				return;
 			}
 		}
@@ -73,7 +80,7 @@ export default class Widget extends Plugin {
 		}
 
 		// Create model selection over widget.
-		const modelElement = editor.editing.mapper.toModelElement( widgetElement );
+		const modelElement = editor.editing.mapper.toModelElement( element );
 
 		editor.document.enqueueChanges( ( ) => {
 			this._setSelectionOverElement( modelElement );
@@ -235,3 +242,19 @@ function isArrowKeyCode( keyCode ) {
 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;
+}

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

@@ -34,6 +34,8 @@ export default class WidgetEngine extends Plugin {
 			}
 
 			const viewSelection = conversionApi.viewSelection;
+
+			// Check if widget was clicked or some sub-element.
 			const selectedElement = viewSelection.getSelectedElement();
 
 			if ( !selectedElement || !isWidget( selectedElement ) ) {

+ 12 - 28
packages/ckeditor5-image/tests/converters.js

@@ -9,8 +9,8 @@ import { createImageViewElement } from '../src/imageengine';
 import { toImageWidget } from '../src/utils';
 import buildModelConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildmodelconverter';
 import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
-import { parse as parseView, getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
-import { stringify as stringifyModel, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 describe( 'Image converters', () => {
 	let editor, document, viewDocument;
@@ -52,17 +52,13 @@ describe( 'Image converters', () => {
 		} );
 
 		it( 'should convert view figure element', () => {
-			test(
-				'<figure class="image"><img src="foo.png" alt="bar baz"></img></figure>',
-				'<image alt="bar baz" src="foo.png"></image>'
-			);
+			editor.setData( '<figure class="image"><img src="foo.png" alt="bar baz"></img></figure>' );
+			expect( getModelData( document, { withoutSelection: true } ) ).to.equal( '<image alt="bar baz" src="foo.png"></image>' );
 		} );
 
 		it( 'should convert without alt', () => {
-			test(
-				'<figure class="image"><img src="foo.png"></img></figure>',
-				'<image src="foo.png"></image>'
-			);
+			editor.setData( '<figure class="image"><img src="foo.png"></img></figure>' );
+			expect( getModelData( document, { withoutSelection: true } ) ).to.equal( '<image src="foo.png"></image>' );
 		} );
 
 		it( 'should not convert if figure element is already consumed', () => {
@@ -72,33 +68,21 @@ describe( 'Image converters', () => {
 				data.output = new ModelElement( 'not-image' );
 			}, { priority: 'high' } );
 
-			test(
-				'<figure class="image"><img src="foo.png" alt="bar baz"></img></figure>',
-				'<not-image></not-image>'
-			);
+			editor.setData( '<figure class="image"><img src="foo.png" alt="bar baz"></img></figure>' );
+			expect( getModelData( document, { withoutSelection: true } ) ).to.equal( '<not-image></not-image>' );
 		} );
 
 		it( 'should not convert image if schema disallows it', () => {
 			schema.disallow( { name: 'image', attributes: [ 'alt', 'src' ], inside: '$root' } );
-			const element = parseView( '<figure class="image"><img src="foo.png"></img></figure>' );
-			const model = dispatcher.convert( element );
 
-			expect( stringifyModel( model ) ).to.equal( '' );
+			editor.setData( '<figure class="image"><img src="foo.png"></img></figure>' );
+			expect( getModelData( document, { withoutSelection: true } ) ).to.equal( '' );
 		} );
 
 		it( 'should not convert image if there is no img element', () => {
-			const element = parseView( '<figure class="image"></figure>' );
-			const model = dispatcher.convert( element );
-
-			expect( stringifyModel( model ) ).to.equal( '' );
+			editor.setData( '<figure class="image"></figure>' );
+			expect( getModelData( document, { withoutSelection: true } ) ).to.equal( '' );
 		} );
-
-		function test( viewString, modelString ) {
-			const element = parseView( viewString );
-			const model = dispatcher.convert( element );
-
-			expect( stringifyModel( model ) ).to.equal( modelString );
-		}
 	} );
 
 	describe( 'modelToViewSelection', () => {

+ 34 - 0
packages/ckeditor5-image/tests/imagecaption/imagecaption.js

@@ -0,0 +1,34 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global window */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import ImageCaption from '../../src/imagecaption/imagecaption';
+import ImageCaptionEngine from '../../src/imagecaption/imagecaptionengine';
+
+describe( 'ImageCaption', () => {
+	let editor;
+
+	beforeEach( () => {
+		const editorElement = window.document.createElement( 'div' );
+		window.document.body.appendChild( editorElement );
+
+		return ClassicTestEditor.create( editorElement, {
+			plugins: [ ImageCaption ]
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+		} );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( ImageCaption ) ).to.instanceOf( ImageCaption );
+	} );
+
+	it( 'should load ImageCaptionEngine plugin', () => {
+		expect( editor.plugins.get( ImageCaptionEngine ) ).to.instanceOf( ImageCaptionEngine );
+	} );
+} );

+ 288 - 0
packages/ckeditor5-image/tests/imagecaption/imagecaptionengine.js

@@ -0,0 +1,288 @@
+/**
+ * @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 ViewAttributeElement from '@ckeditor/ckeditor5-engine/src/view/attributeelement';
+import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position';
+import viewWriter from '@ckeditor/ckeditor5-engine/src/view/writer';
+import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
+import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range';
+import ModelPosition from '@ckeditor/ckeditor5-engine/src/model/position';
+import ImageCaptionEngine from '../../src/imagecaption/imagecaptionengine';
+import ImageEngine from '../../src/imageengine';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import buildViewConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildviewconverter';
+import buildModelConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildmodelconverter';
+
+describe( 'ImageCaptionEngine', () => {
+	let editor, document, viewDocument;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( {
+			plugins: [ ImageCaptionEngine, ImageEngine ]
+		} )
+			.then( newEditor => {
+				editor = newEditor;
+				document = editor.document;
+				viewDocument = editor.editing.view;
+				document.schema.registerItem( 'widget' );
+				document.schema.allow( { name: 'widget', inside: '$root' } );
+				document.schema.allow( { name: 'caption', inside: 'widget' } );
+				document.schema.allow( { name: '$inline', inside: 'widget' } );
+
+				buildViewConverter().for( editor.data.viewToModel ).fromElement( 'widget' ).toElement( 'widget' );
+				buildModelConverter().for( editor.data.modelToView, editor.editing.modelToView ).fromElement( 'widget' ).toElement( 'widget' );
+			} );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( ImageCaptionEngine ) ).to.be.instanceOf( ImageCaptionEngine );
+	} );
+
+	it( 'should set proper schema rules', () => {
+		expect( document.schema.check( { name: 'caption', iniside: 'image' } ) ).to.be.true;
+		expect( document.schema.check( { name: '$inline', inside: 'caption' } ) ).to.be.true;
+		expect( document.schema.limits.has( 'caption' ) );
+	} );
+
+	describe( 'data pipeline', () => {
+		describe( 'view to model', () => {
+			it( 'should convert figcaption inside image figure', () => {
+				editor.setData( '<figure class="image"><img src="foo.png"/><figcaption>foo bar</figcaption></figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '<image src="foo.png"><caption>foo bar</caption></image>' );
+			} );
+
+			it( 'should add empty caption if there is no figcaption', () => {
+				editor.setData( '<figure class="image"><img src="foo.png"/></figure>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '<image src="foo.png"><caption></caption></image>' );
+			} );
+
+			it( 'should not convert figcaption inside other elements than image', () => {
+				editor.setData( '<widget><figcaption>foobar</figcaption></widget>' );
+
+				expect( getModelData( document, { withoutSelection: true } ) )
+					.to.equal( '<widget>foobar</widget>' );
+			} );
+		} );
+
+		describe( 'model to view', () => {
+			it( 'should convert caption element to figcaption', () => {
+				setModelData( document, '<image src="img.png"><caption>Foo bar baz.</caption></image>' );
+
+				expect( editor.getData() ).to.equal( '<figure class="image"><img src="img.png"><figcaption>Foo bar baz.</figcaption></figure>' );
+			} );
+
+			it( 'should not convert caption if it\'s empty', () => {
+				setModelData( document, '<image src="img.png"><caption></caption></image>' );
+
+				expect( editor.getData() ).to.equal( '<figure class="image"><img src="img.png"></figure>' );
+			} );
+
+			it( 'should not convert caption from other elements', () => {
+				setModelData( document, '<widget>foo bar<caption></caption></widget>' );
+				expect( editor.getData() ).to.equal( '<widget>foo bar</widget>' );
+			} );
+		} );
+	} );
+
+	describe( 'editing pipeline', () => {
+		describe( 'model to view', () => {
+			it( 'should convert caption element to figcaption contenteditable', () => {
+				setModelData( document, '<image src="img.png"><caption>Foo bar baz.</caption></image>' );
+
+				expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+					'<figure class="image ck-widget" contenteditable="false">' +
+						'<img src="img.png"></img>' +
+						'<figcaption contenteditable="true">Foo bar baz.</figcaption>' +
+					'</figure>'
+				);
+			} );
+
+			it( 'should not convert caption if it\'s empty', () => {
+				setModelData( document, '<image src="img.png"><caption></caption></image>' );
+
+				expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+					'<figure class="image ck-widget" contenteditable="false"><img src="img.png"></img></figure>'
+				);
+			} );
+
+			it( 'should not convert caption from other elements', () => {
+				setModelData( document, '<widget>foo bar<caption></caption></widget>' );
+				expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( '<widget>foo bar</widget>' );
+			} );
+
+			it( 'should not convert when element is already consumed', () => {
+				editor.editing.modelToView.on(
+					'insert:caption',
+					( evt, data, consumable, conversionApi ) => {
+						consumable.consume( data.item, 'insert' );
+
+						const imageFigure = conversionApi.mapper.toViewElement( data.range.start.parent );
+						const viewElement = new ViewAttributeElement( 'span' );
+
+						const viewPosition = ViewPosition.createAt( imageFigure, 'end' );
+						conversionApi.mapper.bindElements( data.item, viewElement );
+						viewWriter.insert( viewPosition, viewElement );
+					},
+					{ priority: 'high' }
+				);
+
+				setModelData( document, '<image src="img.png"><caption>Foo bar baz.</caption></image>' );
+
+				expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+					'<figure class="image ck-widget" contenteditable="false"><img src="img.png"></img><span></span>Foo bar baz.</figure>'
+				);
+			} );
+		} );
+	} );
+
+	describe( 'inserting image to document', () => {
+		it( 'should add caption element if image does not have it', () => {
+			const image = new ModelElement( 'image', { src: '', alt: '' } );
+			const batch = document.batch();
+
+			document.enqueueChanges( () => {
+				batch.insert( new ModelPosition( document.getRoot(), [ 0 ] ), image );
+			} );
+
+			expect( getModelData( document, { withoutSelection: true } ) ).to.equal(
+				'<image alt="" src=""><caption></caption></image>'
+			);
+		} );
+
+		it( 'should not add caption element if image does not have it', () => {
+			const caption = new ModelElement( 'caption', null, 'foo bar' );
+			const image = new ModelElement( 'image', { src: '', alt: '' }, caption );
+			const batch = document.batch();
+
+			document.enqueueChanges( () => {
+				batch.insert( new ModelPosition( document.getRoot(), [ 0 ] ), image );
+			} );
+
+			expect( getModelData( document, { withoutSelection: true } ) ).to.equal(
+				'<image alt="" src=""><caption>foo bar</caption></image>'
+			);
+		} );
+
+		it( 'should do nothing for other changes than insert', () => {
+			setModelData( document, '<image src=""><caption>foo bar</caption></image>' );
+			const image = document.getRoot().getChild( 0 );
+			const batch = document.batch();
+
+			document.enqueueChanges( () => {
+				batch.setAttribute( image, 'alt', 'alt text' );
+			} );
+
+			expect( getModelData( document, { withoutSelection: true } ) ).to.equal(
+				'<image alt="alt text" src=""><caption>foo bar</caption></image>'
+			);
+		} );
+	} );
+
+	describe( 'editing view', () => {
+		it( 'image should have empty figcaption element when is selected', () => {
+			setModelData( document, '[<image src=""><caption></caption></image>]' );
+
+			expect( getViewData( viewDocument ) ).to.equal(
+				'[<figure class="image ck-widget ck-widget_selected" contenteditable="false">' +
+					'<img src=""></img>' +
+					'<figcaption contenteditable="true"></figcaption>' +
+				'</figure>]'
+			);
+		} );
+
+		it( 'image should not have empty figcaption element when is not selected', () => {
+			setModelData( document, '[]<image src=""><caption></caption></image>' );
+
+			expect( getViewData( viewDocument ) ).to.equal(
+				'[]<figure class="image ck-widget" contenteditable="false">' +
+					'<img src=""></img>' +
+				'</figure>'
+			);
+		} );
+
+		it( 'should not add additional figcaption if one is already present', () => {
+			setModelData( document, '[<image src=""><caption>foo bar</caption></image>]' );
+
+			expect( getViewData( viewDocument ) ).to.equal(
+				'[<figure class="image ck-widget ck-widget_selected" contenteditable="false">' +
+					'<img src=""></img>' +
+					'<figcaption contenteditable="true">foo bar</figcaption>' +
+				'</figure>]'
+			);
+		} );
+
+		it( 'should remove figcaption when caption is empty and image is no longer selected', () => {
+			setModelData( document, '[<image src=""><caption></caption></image>]' );
+
+			document.enqueueChanges( () => {
+				document.selection.removeAllRanges();
+			} );
+
+			expect( getViewData( viewDocument ) ).to.equal(
+				'[]<figure class="image ck-widget" contenteditable="false">' +
+					'<img src=""></img>' +
+				'</figure>'
+			);
+		} );
+
+		it( 'should not remove figcaption when selection is inside it even when it is empty', () => {
+			setModelData( document, '<image src=""><caption>[foo bar]</caption></image>' );
+
+			document.enqueueChanges( () => {
+				document.batch().remove( document.selection.getFirstRange() );
+			} );
+
+			expect( getViewData( viewDocument ) ).to.equal(
+				'<figure class="image ck-widget" contenteditable="false">' +
+					'<img src=""></img>' +
+					'<figcaption contenteditable="true">[]</figcaption>' +
+				'</figure>'
+			);
+		} );
+
+		it( 'should not remove figcaption when selection is moved from it to its image', () => {
+			setModelData( document, '<image src=""><caption>[foo bar]</caption></image>' );
+			const image = document.getRoot().getChild( 0 );
+
+			document.enqueueChanges( () => {
+				document.batch().remove( document.selection.getFirstRange() );
+				document.selection.setRanges( [ ModelRange.createOn( image ) ] );
+			} );
+
+			expect( getViewData( viewDocument ) ).to.equal(
+				'[<figure class="image ck-widget ck-widget_selected" contenteditable="false">' +
+					'<img src=""></img>' +
+					'<figcaption contenteditable="true"></figcaption>' +
+				'</figure>]'
+			);
+		} );
+
+		it( 'should not remove figcaption when selection is moved from it to other image', () => {
+			setModelData( document, '<image src=""><caption>[foo bar]</caption></image><image src=""><caption></caption></image>' );
+			const image = document.getRoot().getChild( 1 );
+
+			document.enqueueChanges( () => {
+				document.selection.setRanges( [ ModelRange.createOn( image ) ] );
+			} );
+
+			expect( getViewData( viewDocument ) ).to.equal(
+				'<figure class="image ck-widget" contenteditable="false">' +
+					'<img src=""></img>' +
+					'<figcaption contenteditable="true">foo bar</figcaption>' +
+				'</figure>' +
+				'[<figure class="image ck-widget ck-widget_selected" contenteditable="false">' +
+					'<img src=""></img>' +
+					'<figcaption contenteditable="true"></figcaption>' +
+				'</figure>]'
+			);
+		} );
+	} );
+} );

+ 68 - 0
packages/ckeditor5-image/tests/imagecaption/utils.js

@@ -0,0 +1,68 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ViewDocument from '@ckeditor/ckeditor5-engine/src/view/document';
+import ViewEditableElement from '@ckeditor/ckeditor5-engine/src/view/editableelement';
+import { captionElementCreator, isCaption, getCaptionFromImage } from '../../src/imagecaption/utils';
+import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
+
+describe( 'image captioning utils', () => {
+	let element, document;
+
+	beforeEach( () => {
+		document = new ViewDocument();
+		const creator = captionElementCreator( document );
+		element = creator();
+	} );
+
+	describe( 'editableCaptionCreator', () => {
+		it( 'should create figcatpion editable element', () => {
+			expect( element ).to.be.instanceOf( ViewEditableElement );
+			expect( element.name ).to.equal( 'figcaption' );
+			expect( isCaption( element ) ).to.be.true;
+		} );
+
+		it( 'should be created in context of proper document', () => {
+			expect( element.document ).to.equal( document );
+		} );
+
+		it( 'should add proper class when element is focused', () => {
+			element.isFocused = true;
+			expect( element.hasClass( 'focused' ) ).to.be.true;
+
+			element.isFocused = false;
+			expect( element.hasClass( 'focused' ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'isCaptionEditable', () => {
+		it( 'should return true for elements created with creator', () => {
+			expect( isCaption( element ) ).to.be.true;
+		} );
+
+		it( 'should return false for other elements', () => {
+			const editable = new ViewEditableElement( 'figcaption', { contenteditable: true } ) ;
+			editable.document = document;
+
+			expect( isCaption( editable ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'getCaptionFromImage', () => {
+		it( 'should return caption element from image element', () => {
+			const dummy = new ModelElement( 'dummy' );
+			const caption = new ModelElement( 'caption' );
+			const image = new ModelElement( 'image', null, [ dummy, caption ] );
+
+			expect( getCaptionFromImage( image ) ).to.equal( caption );
+		} );
+
+		it( 'should return null when caption element is not present', () => {
+			const image = new ModelElement( 'image' );
+
+			expect( getCaptionFromImage( image ) ).to.be.null;
+		} );
+	} );
+} );

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

@@ -132,6 +132,15 @@ describe( 'ImageEngine', () => {
 				expect( getModelData( document, { withoutSelection: true } ) )
 					.to.equal( '' );
 			} );
+
+			it( 'should dispatch conversion for nested elements', () => {
+				const conversionSpy = sinon.spy();
+				editor.data.viewToModel.on( 'element:figcaption', conversionSpy );
+
+				editor.setData( '<figure class="image"><img src="foo.png" alt="alt text" /><figcaption></figcaption></figure>' );
+
+				sinon.assert.calledOnce( conversionSpy );
+			} );
 		} );
 	} );
 

+ 11 - 0
packages/ckeditor5-image/tests/manual/caption.html

@@ -0,0 +1,11 @@
+<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" />
+		<figcaption>CKEditor logo - caption</figcaption>
+	</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. 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. 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. 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. 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"  />
+	</figure>
+</div>

+ 35 - 0
packages/ckeditor5-image/tests/manual/caption.js

@@ -0,0 +1,35 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document, console, window */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import Image from '../../src/image';
+import ImageCaption from '../../src/imagecaption/imagecaption';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import ImageToolbar from '../../src/imagetoolbar';
+import ImageStyle from '../../src/imagestyle/imagestyle';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
+import List from '@ckeditor/ckeditor5-list/src/list';
+
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	plugins: [
+		Enter, Typing, Paragraph, Heading, Image, ImageToolbar,
+		Undo, Clipboard, ImageCaption, ImageStyle, Bold, Italic, Heading, List
+	],
+	toolbar: [ 'headings', 'undo', 'redo', 'bold', 'italic', 'bulletedList', 'numberedList' ]
+} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 11 - 0
packages/ckeditor5-image/tests/manual/caption.md

@@ -0,0 +1,11 @@
+## Image captioning
+
+First image should have `CKEditor logo - caption` displayed.
+Second image shouldn't have any caption and caption editable area should not be visible.
+
+* Click on second image - editable area should be visible.
+* Click on editable area and add some text.
+* Put selection inside paragraph. Caption text and editable area should stay visible.
+* Click on editable area in first image.
+* Remove whole caption text.
+* Put selection inside paragraph. Caption editable area from first image should be hidden.

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

@@ -9,6 +9,7 @@ import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobs
 import buildModelConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildmodelconverter';
 import { widgetize } from '../../src/widget/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';
@@ -33,6 +34,9 @@ describe( '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' )
@@ -50,6 +54,10 @@ describe( 'Widget', () => {
 				buildModelConverter().for( editor.editing.modelToView )
 					.fromElement( 'inline' )
 					.toElement( 'figure' );
+
+				buildModelConverter().for( editor.editing.modelToView )
+					.fromElement( 'nested' )
+					.toElement( () => new ViewEditable( 'figcaption', { contenteditable: true } ) );
 			} );
 	} );
 
@@ -90,6 +98,21 @@ describe( '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 );

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

@@ -9,6 +9,7 @@ import buildModelConverter from '@ckeditor/ckeditor5-engine/src/conversion/build
 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 { widgetize } from '../../src/widget/utils';
 
 describe( 'WidgetEngine', () => {
@@ -23,10 +24,18 @@ describe( 'WidgetEngine', () => {
 				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( () => widgetize( new ViewContainer( 'div' ) ) );
+
+				buildModelConverter().for( editor.editing.modelToView )
+					.fromElement( 'editable' )
+					.toElement( () => new ViewEditable( 'figcaption', { contenteditable: true } ) );
 			} );
 	} );
 
@@ -58,4 +67,15 @@ describe( 'WidgetEngine', () => {
 			'[]<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>'
+		);
+	} );
 } );

+ 27 - 0
packages/ckeditor5-image/theme/imagecaption/theme.scss

@@ -0,0 +1,27 @@
+// Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+// For licensing, see LICENSE.md or http://ckeditor.com/license
+
+@import '~@ckeditor/ckeditor5-theme-lark/theme/helpers/_colors.scss';
+@import '~@ckeditor/ckeditor5-theme-lark/theme/helpers/_shadow.scss';
+@import '~@ckeditor/ckeditor5-theme-lark/theme/helpers/_states.scss';
+
+.ck-widget.image {
+	figcaption {
+		background-color: ck-color( 'foreground' );
+		padding: 10px;
+
+		// The `:focus` styles is applied before `.focused` class inside editables.
+		// These styles show different border for a blink of an eye.
+		&:focus {
+			outline: none;
+			box-shadow: none;
+		}
+
+		&.focused {
+			@include ck-focus-ring( 'outline' );
+			@include ck-box-shadow( $ck-inner-shadow );
+			background-color: ck-color( 'background' );;
+		}
+
+	}
+}