diff --git a/src/imagecaption/imagecaptionengine.js b/src/imagecaption/imagecaptionengine.js index e89b5b6b..4a114153 100644 --- a/src/imagecaption/imagecaptionengine.js +++ b/src/imagecaption/imagecaptionengine.js @@ -12,7 +12,6 @@ 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 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'; @@ -43,16 +42,16 @@ 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. * * @private - * @member {module:engine/view/editableelement~EditableElement} #_lastSelectedEditable + * @member {module:engine/view/editableelement~EditableElement} #_lastSelectedCaption */ + this._viewCaptionsToUpdate = []; + /** * Function used to create editable caption element in the editing view. * @@ -77,102 +76,77 @@ export default class ImageCaptionEngine extends Plugin { .toElement( 'caption' ); // Model to view converter for the data pipeline. - data.modelToView.on( 'insert:caption', captionModelToView( new ViewContainerElement( 'figcaption' ) ) ); + data.modelToView.on( 'insert:caption', captionModelToView( new ViewContainerElement( 'figcaption' ), false ) ); // Model to view converter for the editing pipeline. editing.modelToView.on( 'insert:caption', captionModelToView( this._createCaption ) ); - // 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' } ); + // Always show caption in view when something is inserted in model. + editing.modelToView.on( 'insert', ( evt, data ) => this._fixCaptionVisibility( data.item ), { priority: 'high' } ); + + // Hide caption when everything is removed from it. + editing.modelToView.on( 'remove', ( evt, data ) => this._fixCaptionVisibility( data.sourcePosition.parent ), { priority: 'high' } ); // Update view before each rendering. - this.listenTo( viewDocument, 'render', () => this._updateView(), { priority: 'high' } ); + this.listenTo( viewDocument, 'render', () => this._updateCaptionVisibility(), { priority: 'high' } ); } /** - * Checks if there is an empty caption element to remove from the view. + * Updates view before each rendering, making sure that empty captions (so unnecessary ones) are hidden + * and then visible when the image is selected. * * @private */ - _removeEmptyCaption() { + _updateCaptionVisibility() { + const mapper = this.editor.editing.mapper; const viewSelection = this.editor.editing.view.selection; - const viewCaptionElement = this._lastSelectedEditable; - - // No caption to hide. - if ( !viewCaptionElement ) { - return; - } + const selectedElement = viewSelection.getSelectedElement(); + let viewCaption; - // If selection is placed inside caption - do not remove it. - if ( viewSelection.editableElement === viewCaptionElement ) { - return; + // Hide last selected caption if have no child elements. + if ( this._lastSelectedCaption && !this._lastSelectedCaption.childCount ) { + this._lastSelectedCaption.addClass( 'ck-hidden' ); } - // Do not remove caption if selection is placed on image that contains that caption. - const selectedElement = viewSelection.getSelectedElement(); - + // If whole image widget is selected. if ( selectedElement && isImageWidget( selectedElement ) ) { - const viewImage = viewCaptionElement.findAncestor( element => element == selectedElement ); - - if ( viewImage ) { - return; - } + const modelImage = mapper.toModelElement( selectedElement ); + const modelCaption = getCaptionFromImage( modelImage ); + viewCaption = mapper.toViewElement( modelCaption ); } - // Remove image caption if its empty. - if ( viewCaptionElement.childCount === 0 ) { - const mapper = this.editor.editing.mapper; - viewWriter.remove( ViewRange.createOn( viewCaptionElement ) ); - mapper.unbindViewElement( viewCaptionElement ); + // If selection is placed inside caption. + if ( isCaption( viewSelection.editableElement ) ) { + viewCaption = viewSelection.editableElement; } - } - - /** - * Checks if selected image needs a new caption element inside. - * - * @private - */ - _addCaptionWhenSelected() { - const editing = this.editor.editing; - const selection = editing.view.selection; - const viewImage = selection.getSelectedElement(); - const mapper = editing.mapper; - - if ( viewImage && isImageWidget( viewImage ) ) { - const modelImage = mapper.toModelElement( viewImage ); - const modelCaption = getCaptionFromImage( modelImage ); - let viewCaption = mapper.toViewElement( modelCaption ); - if ( !viewCaption ) { - viewCaption = this._createCaption(); - insertViewCaptionAndBind( viewCaption, modelCaption, viewImage, mapper ); - } - - this._lastSelectedEditable = viewCaption; + if ( viewCaption ) { + viewCaption.removeClass( 'ck-hidden' ); + this._lastSelectedCaption = 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. + * Fixes caption visibility during model to view conversion. + * Checks if changed node is placed inside caption element and fixes it's visibility in the view. * * @private + * @param {module:engine/model/node~Node} node */ - _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; + _fixCaptionVisibility( node ) { + const modelCaption = getParentCaption( node ); + const mapper = this.editor.editing.mapper; + + if ( modelCaption ) { + const viewCaption = mapper.toViewElement( modelCaption ); + + if ( viewCaption ) { + if ( modelCaption.childCount ) { + viewCaption.removeClass( 'ck-hidden' ); + } else { + viewCaption.addClass( 'ck-hidden' ); + } + } } } } @@ -201,42 +175,22 @@ function insertMissingModelCaptionElement( 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 // @param {Function|module:engine/view/element~Element} elementCreator +// @param {Boolean} [hide=true] When set to `false` view element will not be inserted when it's empty. // @return {Function} -function captionModelToView( elementCreator ) { +function captionModelToView( elementCreator, hide = true ) { return ( evt, data, consumable, conversionApi ) => { const captionElement = data.item; - if ( isImage( captionElement.parent ) && ( captionElement.childCount > 0 ) ) { + // Return if element shouldn't be present when empty. + if ( !captionElement.childCount && !hide ) { + return; + } + + if ( isImage( captionElement.parent ) ) { if ( !consumable.consume( data.item, 'insert' ) ) { return; } @@ -246,20 +200,16 @@ function captionModelToView( elementCreator ) { elementCreator.clone( true ) : elementCreator(); + // Hide if empty. + if ( !captionElement.childCount ) { + viewCaption.addClass( 'ck-hidden' ); + } + 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 @@ -273,3 +223,20 @@ function insertViewCaptionAndBind( viewCaption, modelCaption, viewImage, mapper viewWriter.insert( viewPosition, viewCaption ); mapper.bindElements( modelCaption, viewCaption ); } + +/** + * Checks if provided node or one of its ancestors is caption element and returns it. + * + * @param {module:engine/model/node~Node} node + * @returns {module:engine/model/element~Element|null} + */ +function getParentCaption( node ) { + const ancestors = node.getAncestors( { includeNode: true } ); + const caption = ancestors.find( ancestor => ancestor.name == 'caption' ); + + if ( caption && caption.parent && caption.parent.name == 'image' ) { + return caption; + } + + return null; +} diff --git a/tests/imagecaption/imagecaptionengine.js b/tests/imagecaption/imagecaptionengine.js index 30552348..25312b5f 100644 --- a/tests/imagecaption/imagecaptionengine.js +++ b/tests/imagecaption/imagecaptionengine.js @@ -80,7 +80,7 @@ describe( 'ImageCaptionEngine', () => { expect( editor.getData() ).to.equal( '
Foo bar baz.
' ); } ); - it( 'should not convert caption if it\'s empty', () => { + it( 'should not convert caption to figcaption if it\'s empty', () => { setModelData( document, '' ); expect( editor.getData() ).to.equal( '
' ); @@ -106,11 +106,14 @@ describe( 'ImageCaptionEngine', () => { ); } ); - it( 'should not convert caption if it\'s empty', () => { + it( 'should convert caption to element with proper CSS class if it\'s empty', () => { setModelData( document, '' ); expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( - '
' + '
' + + '' + + '
' + + '
' ); } ); @@ -141,6 +144,60 @@ describe( 'ImageCaptionEngine', () => { '
Foo bar baz.
' ); } ); + + it( 'should show caption when something is inserted inside', () => { + setModelData( document, '' ); + const image = document.getRoot().getChild( 0 ); + const caption = image.getChild( 0 ); + + document.enqueueChanges( () => { + const batch = document.batch(); + batch.insert( ModelPosition.createAt( caption ), 'foo bar' ); + } ); + + expect( getViewData( viewDocument ) ).to.equal( + '[]
' + + '' + + '
foo bar
' + + '
' + ); + } ); + + it( 'should hide when everything is removed from caption', () => { + setModelData( document, 'foo bar baz' ); + const image = document.getRoot().getChild( 0 ); + const caption = image.getChild( 0 ); + + document.enqueueChanges( () => { + const batch = document.batch(); + batch.remove( ModelRange.createIn( caption ) ); + } ); + + expect( getViewData( viewDocument ) ).to.equal( + '[]
' + + '' + + '
' + + '
' + ); + } ); + + it( 'should show when not everything is removed from caption', () => { + setModelData( document, 'foo bar baz' ); + const image = document.getRoot().getChild( 0 ); + const caption = image.getChild( 0 ); + + document.enqueueChanges( () => { + const batch = document.batch(); + batch.remove( ModelRange.createFromParentsAndOffsets( caption, 0, caption, 8 ) ); + } ); + + expect( getViewData( viewDocument ) ).to.equal( + '[]
' + + '' + + '
baz
' + + '
' + ); + } ); } ); } ); @@ -153,12 +210,19 @@ describe( 'ImageCaptionEngine', () => { batch.insert( new ModelPosition( document.getRoot(), [ 0 ] ), image ); } ); - expect( getModelData( document, { withoutSelection: true } ) ).to.equal( - '' + expect( getModelData( document ) ).to.equal( + '[]' + ); + + expect( getViewData( viewDocument ) ).to.equal( + '[]
' + + '' + + '
' + + '
' ); } ); - it( 'should not add caption element if image does not have it', () => { + it( 'should not add caption element if image already have it', () => { const caption = new ModelElement( 'caption', null, 'foo bar' ); const image = new ModelElement( 'image', { src: '', alt: '' }, caption ); const batch = document.batch(); @@ -167,8 +231,15 @@ describe( 'ImageCaptionEngine', () => { batch.insert( new ModelPosition( document.getRoot(), [ 0 ] ), image ); } ); - expect( getModelData( document, { withoutSelection: true } ) ).to.equal( - 'foo bar' + expect( getModelData( document ) ).to.equal( + '[]foo bar' + ); + + expect( getViewData( viewDocument ) ).to.equal( + '[]
' + + '' + + '
foo bar
' + + '
' ); } ); @@ -187,39 +258,6 @@ 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, '[]' ); @@ -232,12 +270,13 @@ describe( 'ImageCaptionEngine', () => { ); } ); - it( 'image should not have empty figcaption element when is not selected', () => { + it( 'image should have empty figcaption element with hidden class when not selected', () => { setModelData( document, '[]' ); expect( getViewData( viewDocument ) ).to.equal( '[]
' + '' + + '
' + '
' ); } ); @@ -253,7 +292,7 @@ describe( 'ImageCaptionEngine', () => { ); } ); - it( 'should remove figcaption when caption is empty and image is no longer selected', () => { + it( 'should add hidden class to figcaption when caption is empty and image is no longer selected', () => { setModelData( document, '[]' ); document.enqueueChanges( () => { @@ -263,6 +302,7 @@ describe( 'ImageCaptionEngine', () => { expect( getViewData( viewDocument ) ).to.equal( '[]
' + '' + + '
' + '
' ); } ); @@ -337,7 +377,10 @@ describe( 'ImageCaptionEngine', () => { // Check if there is no figcaption in the view. expect( getViewData( viewDocument ) ).to.equal( - '[]
' + '[]
' + + '' + + '
' + + '
' ); editor.execute( 'undo' ); @@ -350,6 +393,24 @@ describe( 'ImageCaptionEngine', () => { '' ); } ); + + it( 'undo should work after inserting the image', () => { + const image = new ModelElement( 'image' ); + + setModelData( document, '[]' ); + + document.enqueueChanges( () => { + const batch = document.batch(); + + batch.insert( document.selection.anchor, image ); + } ); + + editor.execute( 'undo' ); + + expect( getModelData( document ) ).to.equal( + '[]' + ); + } ); } ); } ); } );