Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

Commit

Permalink
Merge pull request #48 from ckeditor/t/28
Browse files Browse the repository at this point in the history
Feature: Introduced image captions support. Closes #28.
  • Loading branch information
Reinmar committed Feb 21, 2017
2 parents 156774d + 86dbdf2 commit 6bb4069
Show file tree
Hide file tree
Showing 17 changed files with 892 additions and 33 deletions.
10 changes: 10 additions & 0 deletions src/converters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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;
};
}
Expand Down
26 changes: 26 additions & 0 deletions src/imagecaption/imagecaption.js
Original file line number Diff line number Diff line change
@@ -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 changes: 225 additions & 0 deletions src/imagecaption/imagecaptionengine.js
Original file line number Diff line number Diff line change
@@ -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 changes: 63 additions & 0 deletions src/imagecaption/utils.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit 6bb4069

Please sign in to comment.