element-reconversion.md 10 KB


category: framework-deep-dive-conversion order: 50

since: 24.0.0

Element reconversion

{@snippet framework/build-element-reconversion-source}

The reconversion API is a preliminary feature and may not be production ready.

This guide introduces reconversion concept of the downcast (model-to-view) {@link framework/guides/architecture/editing-engine#conversion conversion} for model elements.

To better understand concepts used in this guide we advise that you familiarize with other conversion guides:

  • {@link framework/guides/deep-dive/custom-element-conversion custom element conversion}
  • {@link framework/guides/tutorials/implementing-a-block-widget implementing a block widget}

Atomic converters vs element reconversion

Most editor features are written using separate converters for elements and attributes. This approach enables flexibility of customization and provides separation of concerns. For example, additional image features, like image styles or caption, can be added or removed without the need to change the main image converter. To allow this, we implemented main element-to-element converter for the image, and many granular converters for image's attributes. The other benefit is that change of a model attribute requires minimal changes in the view.

However, this approach in many cases is may be overly complicated. Consider a case in which you need to create a multi-layer view structure for one model element, or a case in which view structure depends on model attribute value. For those cases you can use one converter that produces full view structure from model element, direct children and its attributes. This converter will be used to reconvert model-to-view on every attribute or children change.

An additional perk of using an element reconversion is that the parts of model tree that hasn't been changed, like paragraph and text inside your feature element, will not be reconverted. In other words, their view elements are memoized and re-used inside 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.
  • Writing a one, functional converter is easier to grasp in your project.

Enabling element reconversion

Element reconversion is enabled by setting reconversion trigger configuration (triggerBy) for the {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement elementToElement()} downcast helper.

The model element can be reconverted when:

  • one or many attributes changes (using triggerBy.attributes) or
  • a child is inserted or removed (using triggerBy.children)

Note that, when using children configuration option the current implementation assumes that downcast converter will either:

  • handle element and its children conversion at once
  • will have a "flat" structure

A simple example of 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' ]
	}
} )
  • The downcast converter for myElement creates a <div> with data-owner-id attribute and set of CSS classes.
  • The value of data-owner-id is set from ownerId model element's attribute.
  • The second CSS class is constructed off the type model element's attribute.
  • The triggerBy.attributes defines that element will be converted upon changes of 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 }`
			} )
		} );

Example implementation

In the 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, user can choose a type of the card.

Demo

{@snippet framework/element-reconversion-demo}

Model and view structure

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 model attribute 'cardURL' is converted as view element inside the main view container while the type attributes is translated to a CSS class. Additionally, UI controls are injected to the view after all other child views of the main container. Describing it using atomic converters would introduce convoluted complexity.

Schema

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:
editor.model.schema.register( 'sideCard', {
	allowWhere: '$block',
	isObject: true,
	allowAttributes: [ 'cardType', 'cardURL' ]
} );

// A text-only title.
editor.model.schema.register( 'sideCardTitle', {
	isLimit: true,
	allowIn: 'sideCard'
} );
// Allow text in title...
editor.model.schema.extend( '$text', { allowIn: 'sideCardTitle' } );
// ...but disallow any text attribute inside.
editor.model.schema.addAttributeCheck( context => {
	if ( context.endsWith( 'sideCardTitle $text' ) ) {
		return false;
	}
} );

// A content block which can have any content allowed in $root.
editor.model.schema.register( 'sideCardSection', {
	isLimit: true,
	allowIn: 'sideCard',
	allowContentOf: '$root'
} );

Reconversion definition

To enable element reconversion define for which attributes and children modification the main element will be converted:

editor.conversion.for( 'downcast' ).elementToElement( {
	model: 'sideCard',
	view: ( modelElement, conversionApi ) => downcastSideCard( modelElement, conversionApi ),
	triggerBy: {
		attributes: [ 'cardType', 'cardURL' ],
		children: [ 'sideCardSection' ]
	}
} );

The above definition will use downcastSideCard() function to re-create view when:

  • The complexInfoBOx element is inserted into the model.
  • One of cardType or cardURL has changed.
  • A child sideCardSection is added or removed from the parent sideCard.

Downcast converter details

The function that creates a complete view for the model element:

const downcastSideCard = ( modelElement, { writer, consumable, mapper } ) => {
	const type = modelElement.getAttribute( 'cardType' ) || 'info';

	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.
		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 simple UI that allows to change side card's attributes.
	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.
	}, renderActionsView( editor, modelElement ) ); // See the full code for details.

	writer.insert( writer.createPositionAt( sideCardView, 'end' ), actionsView );

	return toWidget( sideCardView, writer, { widgetLabel: 'Side card' } );
};

By using mapper.bindElements( child, childView ) for <sideCardTitle> and <sideCardSection> you define which view elements corresponds to which model elements. This allows the editor's conversion to re-use existing view elements for title and section children, so they will not be re-converted without a need.

Upcast conversion

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: upcastInfoBox
	} )
	.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.

Full source code

TODO