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

Image captioning #48

Merged
merged 37 commits into from
Feb 21, 2017
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2d143c4
Added initial ImageCaptioningEngine and manual test.
szymonkups Jan 23, 2017
6c83c6b
Added conversion for elements nested inside image figure element.
szymonkups Jan 23, 2017
00318cb
Image captioning engine and manual test.
szymonkups Jan 23, 2017
6196dc2
Image captioning converters.
szymonkups Jan 24, 2017
ffd123e
Use EditableElement for nested editables.
szymonkups Jan 24, 2017
5eace2c
Added tests to captioning converters.
szymonkups Jan 25, 2017
1bdf50e
Image captioning: model to view converter inserts figcaption at the e…
szymonkups Jan 25, 2017
b2eb5ed
Added support for nested editables when clicking on widget.
szymonkups Jan 25, 2017
1db21af
Adding focus class to captioning nested editable.
szymonkups Jan 26, 2017
6392d6e
Merge branch 'master' into t/28
szymonkups Jan 27, 2017
67c38ed
Adding view caption element when image widget is selected.
szymonkups Jan 27, 2017
291125b
Removing empty image caption when image widget is no longer selected.
szymonkups Jan 30, 2017
586f8e6
Update image captioning focus style.
szymonkups Jan 31, 2017
afddbcf
Refactoring image captioning code.
szymonkups Jan 31, 2017
4abc44f
More refactoring in image captioning.
szymonkups Jan 31, 2017
e987db7
Tests form ImageCaptioningEngine.
szymonkups Feb 1, 2017
7ec7dcf
Added more test to ImageCaptioningEngine.
szymonkups Feb 2, 2017
996df20
Added tests for image captioning utils.
szymonkups Feb 2, 2017
064f957
More tests to ImageCaptioningEngine.
szymonkups Feb 2, 2017
13bd1cc
Updated widget tests for integration with nested editables.
szymonkups Feb 2, 2017
eda5d7a
Docs fixes in image captioning.
szymonkups Feb 2, 2017
d3c0f59
Added tests for ImageCaptioning plugin.
szymonkups Feb 2, 2017
766f0cb
Updated image captioning manual test.
szymonkups Feb 2, 2017
da46641
Merge branch 'master' into t/28
szymonkups Feb 2, 2017
760d434
Merge branch 'master' into t/28
Reinmar Feb 7, 2017
09bdb3f
Merge branch 'master' into t/28
szymonkups Feb 9, 2017
35cb5ed
Fixed image converters tests.
szymonkups Feb 9, 2017
c290e29
Renamed ImageCaptioning to ImageCaption.
szymonkups Feb 9, 2017
e4a3a66
Minor fixes in ImageCaption.
szymonkups Feb 9, 2017
045d7c4
Methods renaming, small fixes in ImageCaption.
szymonkups Feb 10, 2017
5aa3ffa
Added more features to image caption manual test.
szymonkups Feb 10, 2017
3ac2144
Removed focus class from image when caption editable is focused.
szymonkups Feb 10, 2017
b304d33
Better checking if clicked inside nested edtiable in widget.
szymonkups Feb 10, 2017
9d6e6c7
Tests: Added list feature to the manual test.
Reinmar Feb 13, 2017
2603f00
Other: Added caption to limits set in schema.
szymonkups Feb 20, 2017
f67581a
Removed mistakenly committed it.only().
Reinmar Feb 21, 2017
86dbdf2
Added a comment about TODO.
Reinmar Feb 21, 2017
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 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,13 @@ export function viewToModelImage() {
modelImage.setAttribute( 'alt', viewImg.getAttribute( 'alt' ) );
}

// Convert children of converted view element and append them to `modelImage`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/imagecaptioning/imagecaptioning.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/imagecaptioning/imagecaptioning
*/

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import ImageCaptioningEngine from './imagecaptioningengine';
import '../../theme/imagecaptioning/theme.scss';

/**
* The image captioning plugin.
*
* @extends module:core/plugin~Plugin
*/
export default class ImageCaptioning extends Plugin {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simply: ImageCaption. We have ImageStyles not ImageStyling.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Besides, since this plugin is empty now, you can merge the engine part into it. Unless... unless we see that adding the placeholder will require some strictly UI-related logic.

OTOH, by splitting the feature beforehand we stick to some pattern, so I'm not sure what would be better.

/**
* @inheritDoc
*/
static get requires() {
return [ ImageCaptioningEngine ];
}
}
224 changes: 224 additions & 0 deletions src/imagecaptioning/imagecaptioningengine.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/**
* @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md.
*/

/**
* @module image/imagecaptioning/imagecaptioningengine
*/

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 { captionEditableCreator, isCaptionEditable, getCaptionFromImage } from './utils';

/**
* The image captioning 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 ImageCaptioningEngine 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/imagecaptioning/imagecaptioningengine~ImageCaptioningEngine} #_lastSelectedEditable
*/

// Schema configuration.
schema.registerItem( 'caption' );
schema.allow( { name: '$inline', inside: 'caption' } );
schema.allow( { name: 'caption', inside: 'image' } );

// Add caption element to each image inserted without it.
document.on( 'change', insertCaptionElement );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe call this function 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( captionEditableCreator( 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 && isCaptionEditable( 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 = captionEditableCreator( 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 insertCaptionElement( 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/imagecaptioning/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/imagecaptioning/utils
*/

import ViewEditableElement from '@ckeditor/ckeditor5-engine/src/view/editableelement';
import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';

const captionSymbol = Symbol( 'imageCaption' );

/**
* Returns function that creates caption editable element for given {@link module:engine/view/document~Document}.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Returns a function", "for the given"

*
* @param {module:engine/view/document~Document} viewDocument
* @return {Function}
*/
export function captionEditableCreator( viewDocument ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

editableCaptionCreator()

return () => {
const editable = new ViewEditableElement( 'figcaption', { contenteditable: true } ) ;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Space before ;.

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 isCaptionEditable( viewElement ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isEditableCaption to pair with the other method. I'm also thinking about isCaption and captionElementCreator. That "editable" part is confusing. Or maybe captionEditableElementCreator? I don't know... :D So maybe let's choose the shortest versions, WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll go with captionElementCreator and isCaption - sounds better and it's shorter too.

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() ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be like:

Array.from( imageModelElement.getChildren() ).find( node => node.is( 'caption' ) )

If we do https://github.com/ckeditor/ckeditor5-engine/issues/809.

if ( node instanceof ModelElement && node.name == 'caption' ) {
return node;
}
}

return null;
}
13 changes: 11 additions & 2 deletions src/widget/widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ 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 { isWidget } from './utils';
import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';

Expand Down Expand Up @@ -52,13 +53,21 @@ 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;

// Do nothing if inside nested editable.
if ( domEventData.target instanceof ViewEditableElement ) {
return;
}

// If target is not a widget element - check if one of the ancestors is.
let widgetElement = domEventData.target;

if ( !isWidget( widgetElement ) ) {
widgetElement = widgetElement.findAncestor( element => isWidget( element ) );
widgetElement = widgetElement.findAncestor( element => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

widgetElement.findAncestor( isWidget );

return isWidget( element );
} );

if ( !widgetElement ) {
return;
Expand Down
Loading