category: framework-deep-dive-conversion order: 50
{@snippet framework/build-element-reconversion-source}
The element reconversion is currently in beta version. The API will be extended to support more cases and will be changing with time.
This guide introduces the concept of the reconversion of model elements during the downcast (model to view) {@link framework/guides/architecture/editing-engine#conversion conversion}.
The reconversion allows simplifying downcast converters for model elements by merging multiple separate converters into a single converter that reacts to more types of model changes.
To better understand the concepts used in this guide we advise that you familiarize yourself with these other conversion guides, too:
In order to convert a model element to its view representation you often write the following converters:
elementToElement() converter. This converter reacts to the insertion of a model element specified in the model field.attributeToAttribute() converters for each attribute. These converters react to changes in the model element attributes and update the view accordingly.This granular approach to conversion is used by many editor features as it ensures extensibility of the base features and provides a separation of concerns. E.g. the base image feature provides conversion for a simple <image src="..."> model element, while the image resize feature adds support for the width and height attributes, the image caption feature for the <figcaption> HTML element, and so on.
Apart from the extensibility aspect, the above approach ensures that a change of a model attribute or structure, requires minimal changes in the view.
However, in some cases where granularity is not necessary this approach may be an overkill. Consider a case in which you need to create a multi-layer view structure for one model element, or a case in which the view structure depends on a value of a model attribute. In such cases, writing a separate converter for a model element and separate converters for each attribute becomes cumbersome.
Thankfully, element reconversion allows merging these converters into into a single converter that reacts to multiple types of model changes (element insertion, its attribute changes and changes in its direct children). This approach can be considered more "functional" as the view callback executed on any of these changes should produce the entire view structure (down to a certain level) without taking into account what state changes just happened.
An additional perk of using element reconversion is that the parts of the model tree that has not been changed, like paragraphs and text inside your feature element, will not be reconverted. In other words, their view elements are kept in memory and re-used inside the changed parent.
To sum up, an element reconversion comes handy for cases where you need to convert a relatively simple model to a complex view structure. And writing a single functional converter is easier to grasp in your project.
Element reconversion is enabled by setting a reconversion trigger configuration (triggerBy) for the {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement elementToElement()} downcast helper.
The model element can be reconverted when:
triggerBy.attributes) ortriggerBy.children)
Note that, when using children configuration option the current implementation assumes that downcast converter will either:
A simple example of an element reconversion configuration demonstrated below:
editor.conversion.for( 'downcast' ).elementToElement( {
model: 'myElement',
view: ( modelElement, { writer } ) => {
return writer.createContainerElement( 'div', {
'data-owner-id': modelElement.getAttribute( 'ownerId' ),
class: `my-element my-element-${ modelElement.getAttribute( 'type' ) }`
} );
},
triggerBy: {
attributes: [ 'ownerId', 'type' ]
}
} )
myElement creates a <div> with a data-owner-id attribute and a set of CSS classes.data-owner-id is set from the ownerId model element's attribute.type model element's attribute.triggerBy.attributes defines that element will be converted upon changes of the onwerId or type attributes.Before CKEditor version 23.1.0 you would have to define a set of atomic converters for the element and for each attribute:
editor.conversion.for( 'downcast' )
.elementToElement( {
model: 'myElement',
view: 'div'
} )
.attributeToAttribute( {
model: 'owner-id',
view: 'data-owner-id'
} )
.attributeToAttribute( {
model: 'type',
view: modelAttributeValue => ( {
key: 'class',
value: `my-element my-element-${ modelAttributeValue }`
} )
} );
In this example implementation we will implement a "card" box which is displayed aside to the main article content. A card will contain a text-only title, one to four content sections and an optional URL. Additionally, the user can choose the type of the card.
{@snippet framework/element-reconversion-demo}
A simplified model markup for the side card looks as follows:
<sideCardSection cardType="info" cardURL="http://cksource.com">
<sideCardTitle>A title</sideCardTitle>
<sideCardSection>
<paragrahp>A content</paragrahp>
</sideCardSection>
</sideCard>
This will be converted to the below view structure:
<aside class="side-card side-card-info">
<div class="side-card-title">Hey! Did you know?</div>
<div class="side-card-section">
<p>Editable content of the <strong>side card</strong>.</p>
</div>
<div class="side-card-section">
<p>Another content box.</p>
</div>
<div class="side-card-actions">
<!-- simple form elements for the editing view -->
</div>
</aside>
In the above example you can observe that the 'cardURL' model attribute is converted as a view element inside the main view container while the type attributes are translated to a CSS class. Additionally, the UI controls are injected to the view after all other child views of the main container. Describing it using atomic converters would introduce a convoluted complexity.
The side card model structure is represented in the editor's {@link framework/guides/deep-dive/schema schema} as follows:
// The main element with attributes for type and URL:
schema.register( 'sideCard', {
allowWhere: '$block',
isObject: true,
allowAttributes: [ 'cardType', 'cardURL' ]
} );
// Disallow side card nesting.
schema.addChildCheck( ( context, childDefinition ) => {
if ( [ ...context.getNames() ].includes( 'sideCard' ) && childDefinition.name === 'sideCard' ) {
return false;
}
} );
// A text-only title.
schema.register( 'sideCardTitle', {
isLimit: true,
allowIn: 'sideCard'
} );
// Allow text in title...
schema.extend( '$text', { allowIn: 'sideCardTitle' } );
// ...but disallow any text attribute inside.
schema.addAttributeCheck( context => {
if ( context.endsWith( 'sideCardTitle $text' ) ) {
return false;
}
} );
// A content block which can have any content allowed in $root.
schema.register( 'sideCardSection', {
isLimit: true,
allowIn: 'sideCard',
allowContentOf: '$root'
} );
To enable an element reconversion define which attributes and children modification the main element will be converted for:
conversion.for( 'editingDowncast' ).elementToElement( {
model: 'sideCard',
view: downcastSideCard( editor, { asWidget: true } ),
triggerBy: {
attributes: [ 'cardType', 'cardURL' ],
children: [ 'sideCardSection' ]
}
} );
The above definition will use the downcastSideCard() function to re-create the view when:
complexInfoBOx element is inserted into the model.cardType or cardURL has changed.sideCardSection is added or removed from the parent sideCard.The function that creates a complete view for the model element:
const downcastSideCard = ( editor, { asWidget } ) => {
return ( modelElement, { writer, consumable, mapper } ) => {
const type = modelElement.getAttribute( 'cardType' ) || 'info';
// Main view element for the side card.
const sideCardView = writer.createContainerElement( 'aside', {
class: `side-card side-card-${ type }`
} );
// Create inner views from side card children.
for ( const child of modelElement.getChildren() ) {
const childView = writer.createEditableElement( 'div' );
// Child is either a "title" or "section".
if ( child.is( 'element', 'sideCardTitle' ) ) {
writer.addClass( 'side-card-title', childView );
} else {
writer.addClass( 'side-card-section', childView );
}
// It is important to consume & bind converted elements.
consumable.consume( child, 'insert' );
mapper.bindElements( child, childView );
// Make it an editable part of the widget.
if ( asWidget ) {
toWidgetEditable( childView, writer );
}
writer.insert( writer.createPositionAt( sideCardView, 'end' ), childView );
}
const urlAttribute = modelElement.getAttribute( 'cardURL' );
// Do not render empty URL field
if ( urlAttribute ) {
const urlBox = writer.createRawElement( 'div', {
class: 'side-card-url'
}, function( domElement ) {
domElement.innerText = `URL: "${ urlAttribute }"`;
} );
writer.insert( writer.createPositionAt( sideCardView, 'end' ), urlBox );
}
// Inner element used to render a simple UI that allows to change side card's attributes.
// It will only be needed in the editing view inside a widgetized element.
// The data output should not contain this section.
if ( asWidget ) {
const actionsView = writer.createRawElement( 'div', {
class: 'side-card-actions',
contenteditable: 'false', // Prevent editing of the element:
'data-cke-ignore-events': 'true' // Allows using custom UI elements inside editing view.
}, createActionsView( editor, modelElement ) ); // See the full code for details.
writer.insert( writer.createPositionAt( sideCardView, 'end' ), actionsView );
toWidget( sideCardView, writer, { widgetLabel: 'Side card' } );
}
return sideCardView;
};
};
By using mapper.bindElements( child, childView ) for <sideCardTitle> and <sideCardSection> you define which view elements correspond to which model elements. This allows the editor's conversion to re-use existing view elements for the title and section children, so they will not be re-converted without a need.
The upcast conversion uses standard element-to-element converters for box & title, and a custom converter for the side card to extract metadata from the data.
editor.conversion.for( 'upcast' )
.elementToElement( {
view: { name: 'aside', classes: [ 'side-card' ] },
model: upcastCard // Details in the full source-code.
} )
.elementToElement( {
view: { name: 'div', classes: [ 'side-card-title' ] },
model: 'sideCardTitle'
} )
.elementToElement( {
view: { name: 'div', classes: [ 'side-card-section' ] },
model: 'sideCardSection'
} );
You can see the details of the upcast converter function (upcastInfoBox()) in the full source code at the end of this guide.
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
import Command from '@ckeditor/ckeditor5-core/src/command';
import { toWidget, toWidgetEditable, findOptimalInsertionPosition } from '@ckeditor/ckeditor5-widget/src/utils';
import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
/**
* Helper for extracting side card type from a view element based on its CSS class.
*/
const getTypeFromViewElement = viewElement => {
for ( const type of [ 'info', 'warning' ] ) {
if ( viewElement.hasClass( `side-card-${ type }` ) ) {
return type;
}
}
return 'info';
};
/**
* Single upcast converter to <sideCard/> element with all its attributes.
*/
const upcastCard = ( viewElement, { writer } ) => {
const sideCard = writer.createElement( 'sideCard' );
const type = getTypeFromViewElement( viewElement );
writer.setAttribute( 'cardType', type, sideCard );
const urlWrapper = [ ...viewElement.getChildren() ].find( child => {
return child.is( 'element', 'div' ) && child.hasClass( 'side-card-url' );
} );
if ( urlWrapper ) {
writer.setAttribute( 'cardURL', urlWrapper.getChild( 0 ).data, sideCard );
}
return sideCard;
};
/**
* Helper for creating DOM button with an editor callback.
*/
const addActionButton = ( text, callback, domElement, editor ) => {
const domDocument = domElement.ownerDocument;
const button = createElement( domDocument, 'button', {}, [ text ] );
button.addEventListener( 'click', () => {
editor.model.change( callback );
} );
domElement.appendChild( button );
return button;
};
/**
* Helper function that creates card editing UI inside the card.
*/
const createActionsView = ( editor, modelElement ) => function( domElement ) {
//
// Set URL action button.
//
addActionButton( 'Set URL', writer => {
// eslint-disable-next-line no-alert
const newURL = prompt( 'Set URL', modelElement.getAttribute( 'cardURL' ) || '' );
writer.setAttribute( 'cardURL', newURL, modelElement );
}, domElement, editor );
const currentType = modelElement.getAttribute( 'cardType' );
const newType = currentType === 'info' ? 'warning' : 'info';
//
// Change card action button.
//
addActionButton( 'Change type', writer => {
writer.setAttribute( 'cardType', newType, modelElement );
}, domElement, editor );
const childCount = modelElement.childCount;
//
// Add content section to a card action button.
//
const addButton = addActionButton( 'Add section', writer => {
writer.insertElement( 'sideCardSection', modelElement, 'end' );
}, domElement, editor );
// Disable the button so only 1-3 content boxes are in the card (there always will be a title).
if ( childCount > 4 ) {
addButton.setAttribute( 'disabled', 'disabled' );
}
//
// Remove content section from a card action button.
//
const removeButton = addActionButton( 'Remove section', writer => {
writer.remove( modelElement.getChild( childCount - 1 ) );
}, domElement, editor );
// Disable the button so only 1-3 content boxes are in the card (there always will be a title).
if ( childCount < 3 ) {
removeButton.setAttribute( 'disabled', 'disabled' );
}
};
/**
* The downcast converter for <sideCard/> element.
*
* It returns a full view structure based on the current state of the model element.
*/
const downcastSideCard = ( editor, { asWidget } ) => {
return ( modelElement, { writer, consumable, mapper } ) => {
const type = modelElement.getAttribute( 'cardType' ) || 'info';
// Main view element for the side card.
const sideCardView = writer.createContainerElement( 'aside', {
class: `side-card side-card-${ type }`
} );
// Create inner views from side card children.
for ( const child of modelElement.getChildren() ) {
const childView = writer.createEditableElement( 'div' );
// Child is either a "title" or "section".
if ( child.is( 'element', 'sideCardTitle' ) ) {
writer.addClass( 'side-card-title', childView );
} else {
writer.addClass( 'side-card-section', childView );
}
// It is important to consume & bind converted elements.
consumable.consume( child, 'insert' );
mapper.bindElements( child, childView );
// Make it an editable part of the widget.
if ( asWidget ) {
toWidgetEditable( childView, writer );
}
writer.insert( writer.createPositionAt( sideCardView, 'end' ), childView );
}
const urlAttribute = modelElement.getAttribute( 'cardURL' );
// Do not render empty URL field
if ( urlAttribute ) {
const urlBox = writer.createRawElement( 'div', {
class: 'side-card-url'
}, function( domElement ) {
domElement.innerText = `URL: "${ urlAttribute }"`;
} );
writer.insert( writer.createPositionAt( sideCardView, 'end' ), urlBox );
}
// Inner element used to render a simple UI that allows to change side card's attributes.
// It will only be needed in the editing view inside a widgetized element.
// The data output should not contain this section.
if ( asWidget ) {
const actionsView = writer.createRawElement( 'div', {
class: 'side-card-actions',
contenteditable: 'false', // Prevent editing of the element:
'data-cke-ignore-events': 'true' // Allows using custom UI elements inside editing view.
}, createActionsView( editor, modelElement ) ); // See the full code for details.
writer.insert( writer.createPositionAt( sideCardView, 'end' ), actionsView );
toWidget( sideCardView, writer, { widgetLabel: 'Side card' } );
}
return sideCardView;
};
};
class InsertCardCommand extends Command {
/**
* Refresh uses schema definition to checks if a sideCard can be inserted in the current selection.
*/
refresh() {
const model = this.editor.model;
const validParent = findOptimalInsertionPosition( model.document.selection, model );
this.isEnabled = model.schema.checkChild( validParent, 'sideCard' );
}
/**
* Creates full side card element with all required children and attributes.
*/
execute() {
const model = this.editor.model;
const selection = model.document.selection;
const insertPosition = findOptimalInsertionPosition( selection, model );
model.change( writer => {
const sideCard = writer.createElement( 'sideCard', { cardType: 'info' } );
const title = writer.createElement( 'sideCardTitle' );
const section = writer.createElement( 'sideCardSection' );
const paragraph = writer.createElement( 'paragraph' );
writer.insert( title, sideCard, 0 );
writer.insert( section, sideCard, 1 );
writer.insert( paragraph, section, 0 );
model.insertContent( sideCard, insertPosition );
writer.setSelection( writer.createPositionAt( title, 0 ) );
} );
}
}
class ComplexBox extends Plugin {
constructor( editor ) {
super( editor );
this._defineSchema();
this._defineConversion();
editor.commands.add( 'insertCard', new InsertCardCommand( editor ) );
this._defineUI();
}
_defineConversion() {
const editor = this.editor;
const conversion = editor.conversion;
conversion.for( 'upcast' )
.elementToElement( {
view: { name: 'aside', classes: [ 'side-card' ] },
model: upcastCard
} )
.elementToElement( {
view: { name: 'div', classes: [ 'side-card-title' ] },
model: 'sideCardTitle'
} )
.elementToElement( {
view: { name: 'div', classes: [ 'side-card-section' ] },
model: 'sideCardSection'
} );
// The downcast conversion must be split as you need a widget in the editing pipeline.
conversion.for( 'editingDowncast' ).elementToElement( {
model: 'sideCard',
view: downcastSideCard( editor, { asWidget: true } ),
triggerBy: {
attributes: [ 'cardType', 'cardURL' ],
children: [ 'sideCardSection' ]
}
} );
// The data downcast is always executed from the current model stat, so the `triggerBy` will take no effect.
conversion.for( 'dataDowncast' ).elementToElement( {
model: 'sideCard',
view: downcastSideCard( editor, { asWidget: false } )
} );
}
_defineSchema() {
const schema = this.editor.model.schema;
// The main element with attributes for type and URL:
schema.register( 'sideCard', {
allowWhere: '$block',
isObject: true,
allowAttributes: [ 'cardType', 'cardURL' ]
} );
// Disallow side card nesting.
schema.addChildCheck( ( context, childDefinition ) => {
if ( [ ...context.getNames() ].includes( 'sideCard' ) && childDefinition.name === 'sideCard' ) {
return false;
}
} );
// A text-only title.
schema.register( 'sideCardTitle', {
isLimit: true,
allowIn: 'sideCard'
} );
// Allow text in title...
schema.extend( '$text', { allowIn: 'sideCardTitle' } );
// ...but disallow any text attribute inside.
schema.addAttributeCheck( context => {
if ( context.endsWith( 'sideCardTitle $text' ) ) {
return false;
}
} );
// A content block which can have any content allowed in $root.
schema.register( 'sideCardSection', {
isLimit: true,
allowIn: 'sideCard',
allowContentOf: '$root'
} );
}
_defineUI() {
const editor = this.editor;
// Defines a simple text button.
editor.ui.componentFactory.add( 'insertCard', locale => {
const button = new ButtonView( locale );
const command = editor.commands.get( 'insertCard' );
button.set( {
withText: true,
icon: false,
label: 'Insert card'
} );
button.bind( 'isEnabled' ).to( command );
button.on( 'execute', () => {
editor.execute( 'insertCard' );
editor.editing.view.focus();
} );
return button;
} );
}
}