diff --git a/src/imagecaption/imagecaptionengine.js b/src/imagecaption/imagecaptionengine.js index 199c2dbe..e89b5b6b 100644 --- a/src/imagecaption/imagecaptionengine.js +++ b/src/imagecaption/imagecaptionengine.js @@ -12,14 +12,18 @@ 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 ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position'; import buildViewConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildviewconverter'; -import ViewMatcher from '@ckeditor/ckeditor5-engine/src/view/matcher'; import { isImage, isImageWidget } from '../image/utils'; -import { captionElementCreator, isCaption, getCaptionFromImage } from './utils'; +import { + captionElementCreator, + isCaption, + getCaptionFromImage, + matchImageCaption +} from './utils'; /** * The image caption engine plugin. @@ -39,14 +43,24 @@ export default class ImageCaptionEngine extends Plugin { const schema = document.schema; const data = editor.data; const editing = editor.editing; + const mapper = editing.mapper; /** * 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 + * @private + * @member {module:engine/view/editableelement~EditableElement} #_lastSelectedEditable */ + /** + * Function used to create editable caption element in the editing view. + * + * @private + * @member {Function} + */ + this._createCaption = captionElementCreator( viewDocument ); + // Schema configuration. schema.registerItem( 'caption' ); schema.allow( { name: '$inline', inside: 'caption' } ); @@ -54,59 +68,30 @@ export default class ImageCaptionEngine extends Plugin { 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; - } ); + document.on( 'change', insertMissingModelCaptionElement ); + // View to model converter for the data pipeline. buildViewConverter() .for( data.viewToModel ) - .from( matcher ) + .from( matchImageCaption ) .toElement( 'caption' ); - // Model to view converter for data pipeline. - data.modelToView.on( - 'insert:caption', - captionModelToView( new ViewContainerElement( 'figcaption' ) ) - ); + // Model to view converter for the 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 ) ) - ); + // Model to view converter for the editing pipeline. + editing.modelToView.on( 'insert:caption', captionModelToView( this._createCaption ) ); - // Adding / removing caption element when there is no text in the model. - const selection = viewDocument.selection; + // When inserting something to caption in the model - make sure that caption in the view is also present. + // See https://github.com/ckeditor/ckeditor5-image/issues/58. + editing.modelToView.on( 'insert', insertMissingViewCaptionElement( this._createCaption, mapper ), { priority: 'high' } ); // 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' } ); + this.listenTo( viewDocument, 'render', () => this._updateView(), { priority: 'high' } ); } /** - * Checks if there is an empty caption element to remove from view. + * Checks if there is an empty caption element to remove from the view. * * @private */ @@ -148,36 +133,55 @@ export default class ImageCaptionEngine extends Plugin { * * @private */ - _addCaption() { + _addCaptionWhenSelected() { const editing = this.editor.editing; const selection = editing.view.selection; - const imageFigure = selection.getSelectedElement(); + const viewImage = selection.getSelectedElement(); const mapper = editing.mapper; - const editableCreator = captionElementCreator( editing.view ); - if ( imageFigure && isImageWidget( imageFigure ) ) { - const modelImage = mapper.toModelElement( imageFigure ); + if ( viewImage && isImageWidget( viewImage ) ) { + const modelImage = mapper.toModelElement( viewImage ); 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 ); + viewCaption = this._createCaption(); + insertViewCaptionAndBind( viewCaption, modelCaption, viewImage, mapper ); } this._lastSelectedEditable = viewCaption; } } + + /** + * Updates view before each rendering, making sure that empty captions (so unnecessary ones) are removed + * and then re-added when the image is selected. + * + * @private + */ + _updateView() { + const selection = this.editor.editing.view.selection; + + // 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._addCaptionWhenSelected(); + + // 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; + } + } } // 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 ) { +function insertMissingModelCaptionElement( evt, changeType, data, batch ) { if ( changeType !== 'insert' ) { return; } @@ -198,6 +202,31 @@ function insertMissingCaptionElement( evt, changeType, data, batch ) { } } +// Returns function that should be executed when model to view conversion is made. It checks if insertion is placed +// inside model caption and makes sure that corresponding view element exists. +// +// @private +// @param {function} creator Function that returns view caption element. +// @param {module:engine/conversion/mapper~Mapper} mapper +// @return {function} +function insertMissingViewCaptionElement( creator, mapper ) { + return ( evt, data ) => { + if ( isInsideCaption( data.item ) ) { + const modelCaption = data.item.parent; + const modelImage = modelCaption.parent; + + const viewImage = mapper.toViewElement( modelImage ); + let viewCaption = mapper.toViewElement( modelCaption ); + + // Image should be already converted to the view. + if ( viewImage && !viewCaption ) { + viewCaption = creator(); + insertViewCaptionAndBind( viewCaption, modelCaption, viewImage, mapper ); + } + } + }; +} + // Creates a converter that converts image caption model element to view element. // // @private @@ -212,14 +241,35 @@ function captionModelToView( elementCreator ) { return; } - const imageFigure = conversionApi.mapper.toViewElement( data.range.start.parent ); - const viewElement = ( elementCreator instanceof ViewElement ) ? + const viewImage = conversionApi.mapper.toViewElement( data.range.start.parent ); + const viewCaption = ( elementCreator instanceof ViewElement ) ? elementCreator.clone( true ) : - elementCreator( data, consumable, conversionApi ); + elementCreator(); - const viewPosition = ViewPosition.createAt( imageFigure, 'end' ); - conversionApi.mapper.bindElements( data.item, viewElement ); - viewWriter.insert( viewPosition, viewElement ); + insertViewCaptionAndBind( viewCaption, data.item, viewImage, conversionApi.mapper ); } }; } + +// Returns `true` if provided `node` is placed inside image's caption. +// +// @private +// @param {module:engine/model/node~Node} node +// @return {Boolean} +function isInsideCaption( node ) { + return !!( node.parent && node.parent.name == 'caption' && node.parent.parent && node.parent.parent.name == 'image' ); +} + +// Inserts `viewCaption` at the end of `viewImage` and binds it to `modelCaption`. +// +// @private +// @param {module:engine/view/containerelement~ContainerElement} viewCaption +// @param {module:engine/model/element~Element} modelCaption +// @param {module:engine/view/containerelement~ContainerElement} viewImage +// @param {module:engine/conversion/mapper~Mapper} mapper +function insertViewCaptionAndBind( viewCaption, modelCaption, viewImage, mapper ) { + const viewPosition = ViewPosition.createAt( viewImage, 'end' ); + + viewWriter.insert( viewPosition, viewCaption ); + mapper.bindElements( modelCaption, viewCaption ); +} diff --git a/src/imagecaption/utils.js b/src/imagecaption/utils.js index 544ff76c..3a4dc7a9 100644 --- a/src/imagecaption/utils.js +++ b/src/imagecaption/utils.js @@ -47,7 +47,7 @@ export function isCaption( viewElement ) { } /** - * Returns caption's model element from given image element. Returns `null` if no caption is found. + * Returns caption 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} @@ -61,3 +61,22 @@ export function getCaptionFromImage( imageModelElement ) { return null; } + +/** + * {@link module:engine/view/matcher~Matcher} pattern. Checks if given element is `figcaption` element and is placed + * inside image `figure` element. + * + * @param {module:engine/view/element~Element} element + * @returns {Object|null} Returns object accepted by {@link module:engine/view/matcher~Matcher} or `null` if element + * cannot be matched. + */ +export function matchImageCaption( 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; +} diff --git a/tests/imagecaption/imagecaptionengine.js b/tests/imagecaption/imagecaptionengine.js index dde70b8c..5110b76a 100644 --- a/tests/imagecaption/imagecaptionengine.js +++ b/tests/imagecaption/imagecaptionengine.js @@ -12,6 +12,7 @@ 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/image/imageengine'; +import UndoEngine from '@ckeditor/ckeditor5-undo/src/undoengine'; 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'; @@ -22,7 +23,7 @@ describe( 'ImageCaptionEngine', () => { beforeEach( () => { return VirtualTestEditor.create( { - plugins: [ ImageCaptionEngine, ImageEngine ] + plugins: [ ImageCaptionEngine, ImageEngine, UndoEngine ] } ) .then( newEditor => { editor = newEditor; @@ -186,6 +187,39 @@ describe( 'ImageCaptionEngine', () => { } ); } ); + describe( 'inserting into image caption', () => { + it( 'should add view caption if insertion was made to model caption', () => { + setModelData( document, '' ); + const image = document.getRoot().getChild( 0 ); + const caption = image.getChild( 0 ); + + // Check if there is no caption in the view + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( + '
' + ); + + document.enqueueChanges( () => { + const batch = document.batch(); + const position = ModelPosition.createAt( caption ); + + batch.insert( position, 'foo bar baz' ); + } ); + + // Check if data is inside model. + expect( getModelData( document, { withoutSelection: true } ) ).to.equal( + 'foo bar baz' + ); + + // Check if view has caption. + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( + '
' + + '' + + '
foo bar baz
' + + '
' + ); + } ); + } ); + describe( 'editing view', () => { it( 'image should have empty figcaption element when is selected', () => { setModelData( document, '[]' ); @@ -284,5 +318,38 @@ describe( 'ImageCaptionEngine', () => { ']' ); } ); + + describe( 'undo/redo integration', () => { + it( 'should create view element after redo', () => { + setModelData( document, '[foo bar baz]' ); + + const modelRoot = document.getRoot(); + const modelImage = modelRoot.getChild( 0 ); + const modelCaption = modelImage.getChild( 0 ); + + // Remove text and selection from caption. + document.enqueueChanges( () => { + const batch = document.batch(); + + batch.remove( ModelRange.createIn( modelCaption ) ); + document.selection.removeAllRanges(); + } ); + + // Check if there is no figcaption in the view. + expect( getViewData( viewDocument ) ).to.equal( + '[]
' + ); + + editor.execute( 'undo' ); + + // Check if figcaption is back with contents. + expect( getViewData( viewDocument ) ).to.equal( + '
' + + '' + + '
{foo bar baz}
' + + '
' + ); + } ); + } ); } ); } ); diff --git a/tests/imagecaption/utils.js b/tests/imagecaption/utils.js index fd13827f..bd50f6e2 100644 --- a/tests/imagecaption/utils.js +++ b/tests/imagecaption/utils.js @@ -5,7 +5,13 @@ 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 ViewElement from '@ckeditor/ckeditor5-engine/src/view/element'; +import { + captionElementCreator, + isCaption, + getCaptionFromImage, + matchImageCaption +} from '../../src/imagecaption/utils'; import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element'; describe( 'image captioning utils', () => { @@ -65,4 +71,39 @@ describe( 'image captioning utils', () => { expect( getCaptionFromImage( image ) ).to.be.null; } ); } ); + + describe( 'matchImageCaption', () => { + it( 'should return null for element that is not a figcaption', () => { + const element = new ViewElement( 'div' ); + + expect( matchImageCaption( element ) ).to.be.null; + } ); + + it( 'should return null if figcaption has no parent', () => { + const element = new ViewElement( 'figcaption' ); + + expect( matchImageCaption( element ) ).to.be.null; + } ); + + it( 'should return null if figcaption\'s parent is not a figure', () => { + const element = new ViewElement( 'figcaption' ); + new ViewElement( 'div', null, element ); + + expect( matchImageCaption( element ) ).to.be.null; + } ); + + it( 'should return null if parent has no image class', () => { + const element = new ViewElement( 'figcaption' ); + new ViewElement( 'figure', null, element ); + + expect( matchImageCaption( element ) ).to.be.null; + } ); + + it( 'should return object if element is a valid caption', () => { + const element = new ViewElement( 'figcaption' ); + new ViewElement( 'figure', { class: 'image' }, element ); + + expect( matchImageCaption( element ) ).to.deep.equal( { name: true } ); + } ); + } ); } );