This repository has been archived by the owner on Jun 26, 2020. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 37
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #48 from ckeditor/t/28
Feature: Introduced image captions support. Closes #28.
- Loading branch information
Showing
17 changed files
with
892 additions
and
33 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 ]; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 ); | ||
} | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
Oops, something went wrong.