-
Notifications
You must be signed in to change notification settings - Fork 37
Image captioning #48
Changes from 25 commits
2d143c4
6c83c6b
00318cb
6196dc2
ffd123e
5eace2c
1bdf50e
b2eb5ed
1db21af
6392d6e
67c38ed
291125b
586f8e6
afddbcf
4abc44f
e987db7
7ec7dcf
996df20
064f957
13bd1cc
eda5d7a
d3c0f59
766f0cb
da46641
760d434
09bdb3f
35cb5ed
c290e29
e4a3a66
045d7c4
5aa3ffa
3ac2144
b304d33
9d6e6c7
2603f00
f67581a
86dbdf2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Simply: There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ]; | ||
} | ||
} |
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 ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe call this function |
||
|
||
// 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 ); | ||
} | ||
}; | ||
} |
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}. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
return () => { | ||
const editable = new ViewEditableElement( 'figcaption', { contenteditable: true } ) ; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll go with |
||
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() ) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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'; | ||
|
||
|
@@ -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 => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
return isWidget( element ); | ||
} ); | ||
|
||
if ( !widgetElement ) { | ||
return; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please link to https://github.com/ckeditor/ckeditor5-engine/issues/736