8
0
Pārlūkot izejas kodu

Update custom element converter guide.

Maciej Gołaszewski 5 gadi atpakaļ
vecāks
revīzija
618501c5b4

+ 107 - 108
packages/ckeditor5-engine/docs/_snippets/framework/extending-content-custom-element-converter.js

@@ -7,110 +7,110 @@
 
 import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
 
+class InfoBox {
+	constructor( editor ) {
+		editor.model.schema.register( 'infoBox', {
+			allowWhere: '$block',
+			allowContentOf: '$root',
+			isObject: true,
+			allowAttributes: [ 'infoBoxType' ]
+		} );
+
+		editor.conversion.for( 'upcast' ).add( dispatcher => dispatcher.on( 'element:div', upcastConverter ) );
+
+		// The downcast conversion must be split as we need a widget in the editing pipeline.
+		editor.conversion.for( 'editingDowncast' ).add( dispatcher => dispatcher.on( 'insert:infoBox', editingDowncastConverter ) );
+		editor.conversion.for( 'dataDowncast' ).add( dispatcher => dispatcher.on( 'insert:infoBox', dataDowncastConverter ) );
+	}
+}
+
+function upcastConverter( event, data, conversionApi ) {
+	const viewInfoBox = data.viewItem;
+
+	// Detect that view element is an info-box div. Otherwise, it should be handled by another converter.
+	if ( !viewInfoBox.hasClass( 'info-box' ) ) {
+		return;
+	}
+
+	// Create a model structure.
+	const modelElement = conversionApi.writer.createElement( 'infoBox', {
+		infoBoxType: getTypeFromViewElement( viewInfoBox )
+	} );
+
+	// Try to safely insert element - if it returns false the element can't be safely inserted into the content,
+	// and the conversion process must stop.
+	if ( !conversionApi.safeInsert( modelElement, data.modelCursor ) ) {
+		return;
+	}
+
+	// Mark info-box div as handled by this converter.
+	conversionApi.consumable.consume( viewInfoBox, { name: true } );
+
+	// Let's assume that the HTML structure is always the same.
+	// If you don't control the order of view elements a more sophisticated search might be needed.
+	const viewInfoBoxTitle = viewInfoBox.getChild( 0 );
+	const viewInfoBoxContent = viewInfoBox.getChild( 1 );
+
+	// Mark info-box inner elements as handled by this converter.
+	conversionApi.consumable.consume( viewInfoBoxTitle, { name: true } );
+	conversionApi.consumable.consume( viewInfoBoxContent, { name: true } );
+
+	// Let the editor handle children of the info-box content conversion.
+	conversionApi.convertChildren( viewInfoBoxContent, modelElement );
+
+	// Conversion requires updating result data structure properly.
+	conversionApi.updateConversionResult( modelElement, data );
+}
+
+function editingDowncastConverter( event, data, conversionApi ) {
+	let { infoBox, infoBoxContent, infoBoxTitle } = createViewElements( data, conversionApi );
+
+	// Decorate view items as widgets.
+	infoBox = toWidget( infoBox, conversionApi.writer, { label: 'simple box widget' } );
+	infoBoxContent = toWidgetEditable( infoBoxContent, conversionApi.writer );
+
+	insertViewElements( data, conversionApi, infoBox, infoBoxTitle, infoBoxContent );
+}
+
+function dataDowncastConverter( event, data, conversionApi ) {
+	const { infoBox, infoBoxContent, infoBoxTitle } = createViewElements( data, conversionApi );
+
+	insertViewElements( data, conversionApi, infoBox, infoBoxTitle, infoBoxContent );
+}
+
+function createViewElements( data, conversionApi ) {
+	const type = data.item.getAttribute( 'infoBoxType' );
+
+	const infoBox = conversionApi.writer.createContainerElement( 'div', { class: `info-box info-box-${ type.toLowerCase() }` } );
+	const infoBoxContent = conversionApi.writer.createEditableElement( 'div', { class: 'info-box-content' } );
+
+	const infoBoxTitle = conversionApi.writer.createUIElement( 'div', { class: 'info-box-title' }, function( domDocument ) {
+		const domElement = this.toDomElement( domDocument );
+
+		domElement.innerText = type;
+
+		return domElement;
+	} );
+
+	return { infoBox, infoBoxContent, infoBoxTitle };
+}
+
+function insertViewElements( data, conversionApi, infoBox, infoBoxTitle, infoBoxContent ) {
+	conversionApi.consumable.consume( data.item, 'insert' );
+
+	conversionApi.writer.insert( conversionApi.writer.createPositionAt( infoBox, 0 ), infoBoxTitle );
+	conversionApi.writer.insert( conversionApi.writer.createPositionAt( infoBox, 1 ), infoBoxContent );
+
+	conversionApi.mapper.bindElements( data.item, infoBox );
+	conversionApi.mapper.bindElements( data.item, infoBoxContent );
+
+	conversionApi.writer.insert( conversionApi.mapper.toViewPosition( data.range.start ), infoBox );
+}
+
 ClassicEditor
 	.create( document.querySelector( '#editor-custom-element-converter' ), {
 		cloudServices: CS_CONFIG,
-		extraPlugins: [ function( editor ) {
-			editor.model.schema.register( 'infoBox', {
-				allowWhere: '$block',
-				allowContentOf: '$root',
-				isObject: true,
-				allowAttributes: [ 'infoBoxType' ]
-			} );
-
-			editor.conversion.for( 'upcast' ).add( dispatcher => dispatcher.on( 'element:div', ( event, data, conversionApi ) => {
-				const viewInfoBox = data.viewItem;
-				// . Detect that converted div is the one we need to convert.
-
-				// . Create model structure.
-				const modelElement = conversionApi.writer.createElement( 'infoBox', {
-					infoBoxType: getTypeFromViewElement( viewInfoBox )
-				} );
-
-				// . Try to safely insert element - if it returns false the element can't be safely inserted into
-				// the content and the conversion process must stop.
-				if ( !conversionApi.safeInsert( modelElement, data.modelCursor ) ) {
-					return;
-				}
-
-				// . Mark info-box div as handled by this converter.
-				if ( !conversionApi.consumable.consume( viewInfoBox, { name: true } ) ) {
-					return; // If already handled by another converter.
-				}
-
-				// For the sake of simplicity we assume that HTML structure is always the same.
-				// If you can't control the order of view elements a more sophisticated search might be needed.
-				const viewInfoBoxTitle = viewInfoBox.getChild( 0 );
-				const viewInfoBoxContent = viewInfoBox.getChild( 1 );
-
-				conversionApi.consumable.consume( viewInfoBoxTitle, { name: true } );
-				conversionApi.consumable.consume( viewInfoBoxContent, { name: true } );
-
-				// . Make editor handle children conversion.
-				conversionApi.convertChildren( viewInfoBoxContent, modelElement );
-
-				// . Conversion requires updating data structure properly.
-				conversionApi.updateConversionResult( modelElement, data );
-			} ) );
-
-			editor.conversion.for( 'downcast' ).add(
-				dispatcher => dispatcher.on( 'insert:infoBox', ( event, data, conversionApi ) => {
-					const modelElement = data.item;
-					const viewWriter = conversionApi.writer;
-
-					const type = modelElement.getAttribute( 'infoBoxType' );
-
-					const infoBox = toWidget(
-						viewWriter.createContainerElement( 'div', { class: `info-box info-box-${ type }` } ),
-						viewWriter,
-						{ label: 'simple box widget' }
-					);
-
-					const infoBoxTitle = viewWriter.createUIElement( 'div', { class: 'info-box-title' }, function( domDocument ) {
-						const domElement = this.toDomElement( domDocument );
-
-						domElement.innerText = type;
-
-						return domElement;
-					} );
-
-					const infoBoxContent = toWidgetEditable(
-						viewWriter.createEditableElement( 'div', { class: 'info-box-content' } ),
-						viewWriter
-					);
-
-					conversionApi.consumable.consume( modelElement, 'insert' );
-
-					const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
-					viewWriter.insert( viewWriter.createPositionAt( infoBox, 0 ), infoBoxTitle );
-					viewWriter.insert( viewWriter.createPositionAt( infoBox, 1 ), infoBoxContent );
-
-					// Bind both...
-					conversionApi.mapper.bindElements( modelElement, infoBox );
-					conversionApi.mapper.bindElements( modelElement, infoBoxContent );
-
-					conversionApi.writer.insert( viewPosition, infoBox );
-				} )
-			);
-		} ],
-		toolbar: [
-			'heading',
-			'|',
-			'bold',
-			'italic',
-			'link',
-			'bulletedList',
-			'numberedList',
-			'|',
-			'outdent',
-			'indent',
-			'|',
-			'blockQuote',
-			'insertTable',
-			'mediaEmbed',
-			'undo',
-			'redo'
-		],
+		extraPlugins: [ InfoBox ],
 		image: {
 			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
 		},
@@ -120,10 +120,10 @@ ClassicEditor
 				'tableRow',
 				'mergeTableCells'
 			]
+		},
+		toolbar: {
+			viewportTopOffset: window.getViewportTopOffsetConfig()
 		}
-		// toolbar: {
-		// 	viewportTopOffset: window.getViewportTopOffsetConfig()
-		// }
 	} )
 	.then( editor => {
 		window.editor = editor;
@@ -136,13 +136,12 @@ function getTypeFromViewElement( viewElement ) {
 	const classNames = [ ...viewElement.getClassNames() ];
 
 	if ( classNames.includes( 'info-box-info' ) ) {
-		return 'info';
+		return 'Info';
 	}
 
 	if ( classNames.includes( 'info-box-warning' ) ) {
-		return 'warning';
+		return 'Warning';
 	}
 
-	return 'none';
+	return 'None';
 }
-

+ 145 - 64
packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion-custom-element-converter.md

@@ -6,9 +6,9 @@ order: 30
 
 {@snippet framework/build-custom-element-converter-source}
 
-## Moving from general converters to an event based
+## Intro
 
-Let's assume that content in your application contains info boxes. As for now it was only required to wrap part of a content in a `<div>` element:
+Let's assume that content in your application contains "info boxes". As for now it was only required to wrap part of a content in a `<div>` element:
 
 <!-- Optional: Image of a simple info box -->
 
@@ -17,29 +17,47 @@ The HTML structure of info box:
 ```html
 <div class="info-box">
 	<!-- any editable content -->
+	<p>This is<strong>important!</strong></p>
 </div>
 ```
 
-This can be easily done with below schema and converters:
+This will be stored in the model as:
+
+```html
+<infoBox>
+	<!-- any $block, like: -->
+	<paragraph><$text>This is</$text><$text bold="true">important!</$text></paragraph>
+</infoBox>
+```
+
+This can be easily done with below schema and converters in a simple `InfoBox` plugin:
 
 ```js
-// 1. Define infoBox as a object that can be contain any other content.
-editor.model.schema.register( 'infoBox', {
-	allowWhere: '$block',
-	allowContentOf: '$root',
-	isObject: true
-} );
+class InfoBox {
+	constructor( editor ) {
+		// 1. Define infoBox as a object that can be contain any other content.
+		editor.model.schema.register( 'infoBox', {
+			allowWhere: '$block',
+			allowContentOf: '$root',
+			isObject: true
+		} );
 
-// 2. Conversion is straight forward:
-editor.conversion.elementToElement( {
-	model: 'infoBox',
-	view: {
-		name: 'div',
-		classes: 'info-box'
+		// 2. Conversion is straight forward:
+		editor.conversion.elementToElement( {
+			model: 'infoBox',
+			view: {
+				name: 'div',
+				classes: 'info-box'
+			}
+		} );
 	}
-} );
+}
 ```
 
+## Moving from general converters to an event based
+
+### Updated data & model structure
+
 This basic definition allows to nest some part of a content inside a pre-defined "info-box". However, with time, the requirements has changed and there's a need for adding a set of types of info boxes. The new info box will have a structure:
 
 ```html
@@ -47,14 +65,28 @@ This basic definition allows to nest some part of a content inside a pre-defined
 	<div class="info-box-title">Warning</div>
 	<div class="info-box-content">
 		<!-- any editable content -->
+		<p>This is<strong>important!</strong></p>
 	</div>
 </div>
 ```
 
+As the "Warning" shouldn't be editable and defines a type of the info box we can store this as an attribute of a `<infoBox>` element:
+
+```html
+<infoBox infoBoxType="warning">
+	<!-- any $block, like: -->
+	<paragraph><$text>This is</$text><$text bold="true">important!</$text></paragraph>
+</infoBox>
+```
+
+### Demo
+
 Below is a demo of the editor with example info box.
 
 {@snippet framework/extending-content-custom-element-converter}
 
+### Updated model schema
+
 The type of the box is defined by the additional class to the `"info-box"` on main `<div>` but it is also visible as text in `<div class="info-box-title">`. All the info box content must be placed in `<div class="info-box-content">`.
 
 For the above requirements we can see that the model structure of the `infoBox` doesn't need to change much. The only addition to the model is an attribute that will hold information of the info box type:
@@ -68,95 +100,144 @@ editor.model.schema.register( 'infoBox', {
 } );
 ```
 
-The info box type conversion can be achieved using `attributeToAttribute` converter by converting `info-box-*` css classes to `infoBoxType` attribute. However, adding non-converted layers of the content requries to write an event based converter to have better control over conversion process.
+### Event based element to element upcast converter
+
+The info box type conversion can be achieved using `attributeToAttribute` converter by converting `info-box-*` css classes to `infoBoxType` attribute. However, adding non-converted layers of the content requires to write an event based converter to have better control over conversion process.
 
 For the proposed HTML structure we need to:
 
-1. Skip conversion of `info-box-title`.
-2. Convert children of `info-box-content` as `infoBox` children.
+1. Create model's `<infoBox>` element with `infoBoxType` attribute.
+1. Skip conversion of `<div class="info-box-title">` as the information about type can be obtained from the main info-box CSS classes.
+1. Convert children of `<div class="info-box-content">` and insert them into model's `<infoBox>`.
 
 ```js
-editor.conversion.for( 'upcast' ).add( dispatcher => dispatcher.on( 'element:div', ( event, data, conversionApi ) => {
+function upcastConverter( event, data, conversionApi ) {
 	const viewInfoBox = data.viewItem;
-	// Detect that converted div is the one we need to convert.
 
-	// Create model structure.
+	// Detect that view element is an info-box div. Otherwise, it should be handled by another converter.
+	if ( !viewInfoBox.hasClass( 'info-box' ) ) {
+		return;
+	}
+
+	// Create a model structure.
 	const modelElement = conversionApi.writer.createElement( 'infoBox', {
 		infoBoxType: getTypeFromViewElement( viewInfoBox )
 	} );
 
-	// Try to safely insert element - if it returns false the element can't be safely inserted into
-	// the content and the conversion process must stop.
+	// Try to safely insert element - if it returns false the element can't be safely inserted into the content,
+	// and the conversion process must stop.
 	if ( !conversionApi.safeInsert( modelElement, data.modelCursor ) ) {
 		return;
 	}
 
 	// Mark info-box div as handled by this converter.
-	if ( !conversionApi.consumable.consume( viewInfoBox, { name: true } ) ) {
-		return; // If already handled by another converter.
-	}
+	conversionApi.consumable.consume( viewInfoBox, { name: true } );
 
-	// For the sake of simplicity we assume that HTML structure is always the same.
-	// If you can't control the order of view elements a more sophisticated search might be needed.
+	// Let's assume that the HTML structure is always the same.
+	// If you don't control the order of view elements a more sophisticated search might be needed.
 	const viewInfoBoxTitle = viewInfoBox.getChild( 0 );
 	const viewInfoBoxContent = viewInfoBox.getChild( 1 );
 
+	// Mark info-box inner elements as handled by this converter.
 	conversionApi.consumable.consume( viewInfoBoxTitle, { name: true } );
 	conversionApi.consumable.consume( viewInfoBoxContent, { name: true } );
 
-	// Make editor handle children conversion.
+	// Let the editor handle children of the info-box content conversion.
 	conversionApi.convertChildren( viewInfoBoxContent, modelElement );
 
-	// Conversion requires updating data structure properly.
+	// Conversion requires updating result data structure properly.
 	conversionApi.updateConversionResult( modelElement, data );
-} ) );
+}
 
+// Helper funciton to read type form the view classes.
 function getTypeFromViewElement( viewElement ) {
-	// Should be updated with proper logic.
-	return 'info';
+	const classNames = [ ...viewElement.getClassNames() ];
+
+	if ( classNames.includes( 'info-box-info' ) ) {
+		return 'Info';
+	}
+
+	if ( classNames.includes( 'info-box-warning' ) ) {
+		return 'Warning';
+	}
+
+	return 'None';
 }
 ```
 
-The downcast converter must be written as well.
+### Accompanying downcast conversion & updated plugin code
+
+<info-box>
+	See the {@link builds/guides/implementing-a-block-widget Implementing a block widget} to learn about widget system concepts.
+</info-box>
+
 
 ```js
-editor.conversion.for( 'downcast' ).add(
-	dispatcher => dispatcher.on( 'insert:infoBox', ( event, data, conversionApi ) => {
-		const modelElement = data.item;
-		const viewWriter = conversionApi.writer;
+function editingDowncastConverter( event, data, conversionApi ) {
+	let { infoBox, infoBoxContent, infoBoxTitle } = createViewElements( data, conversionApi );
 
-		const type = modelElement.getAttribute( 'infoBoxType' );
+	// Decorate view items as widgets.
+	infoBox = toWidget( infoBox, conversionApi.writer, { label: 'simple box widget' } );
+	infoBoxContent = toWidgetEditable( infoBoxContent, conversionApi.writer );
 
-		const infoBox = toWidget(
-			viewWriter.createContainerElement( 'div', { class: `info-box info-box-${ type }` } ),
-			viewWriter,
-			{ label: 'simple box widget' }
-		);
+	insertViewElements( data, conversionApi, infoBox, infoBoxTitle, infoBoxContent );
+}
 
-		const infoBoxTitle = viewWriter.createUIElement( 'div', { class: 'info-box-title' }, function( domDocument ) {
-			const domElement = this.toDomElement( domDocument );
+function dataDowncastConverter( event, data, conversionApi ) {
+	const { infoBox, infoBoxContent, infoBoxTitle } = createViewElements( data, conversionApi );
 
-			domElement.innerText = type;
+	insertViewElements( data, conversionApi, infoBox, infoBoxTitle, infoBoxContent );
+}
 
-			return domElement;
-		} );
+function createViewElements( data, conversionApi ) {
+	const type = data.item.getAttribute( 'infoBoxType' );
 
-		const infoBoxContent = toWidgetEditable(
-			viewWriter.createEditableElement( 'div', { class: 'info-box-content' } ),
-			viewWriter
-		);
+	const infoBox = conversionApi.writer.createContainerElement( 'div', { class: `info-box info-box-${ type.toLowerCase() }` } );
+	const infoBoxContent = conversionApi.writer.createEditableElement( 'div', { class: 'info-box-content' } );
 
-		conversionApi.consumable.consume( modelElement, 'insert' );
+	const infoBoxTitle = conversionApi.writer.createUIElement( 'div', { class: 'info-box-title' }, function( domDocument ) {
+		const domElement = this.toDomElement( domDocument );
 
-		const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
-		viewWriter.insert( viewWriter.createPositionAt( infoBox, 0 ), infoBoxTitle );
-		viewWriter.insert( viewWriter.createPositionAt( infoBox, 1 ), infoBoxContent );
+		domElement.innerText = type;
 
-		// Bind both...
-		conversionApi.mapper.bindElements( modelElement, infoBox );
-		conversionApi.mapper.bindElements( modelElement, infoBoxContent );
+		return domElement;
+	} );
+
+	return { infoBox, infoBoxContent, infoBoxTitle };
+}
 
-		conversionApi.writer.insert( viewPosition, infoBox );
-	} )
-);
+function insertViewElements( data, conversionApi, infoBox, infoBoxTitle, infoBoxContent ) {
+	conversionApi.consumable.consume( data.item, 'insert' );
+
+	conversionApi.writer.insert( conversionApi.writer.createPositionAt( infoBox, 0 ), infoBoxTitle );
+	conversionApi.writer.insert( conversionApi.writer.createPositionAt( infoBox, 1 ), infoBoxContent );
+
+	conversionApi.mapper.bindElements( data.item, infoBox );
+	conversionApi.mapper.bindElements( data.item, infoBoxContent );
+
+	conversionApi.writer.insert( conversionApi.mapper.toViewPosition( data.range.start ), infoBox );
+}
+```
+
+The updated `InfoBox` plugin:
+
+```js
+class InfoBox {
+	constructor( editor ) {
+		// Schema definition
+		editor.model.schema.register( 'infoBox', {
+			allowWhere: '$block',
+			allowContentOf: '$root',
+			isObject: true,
+			allowAttributes: [ 'infoBoxType' ]
+		} );
+
+		// Upcast converter.
+		editor.conversion.for( 'upcast' ).add( dispatcher => dispatcher.on( 'element:div', upcastConverter ) );
+
+		// The downcast conversion must be split as we need a widget in the editing pipeline.
+		editor.conversion.for( 'editingDowncast' ).add( dispatcher => dispatcher.on( 'insert:infoBox', editingDowncastConverter ) );
+		editor.conversion.for( 'dataDowncast' ).add( dispatcher => dispatcher.on( 'insert:infoBox', dataDowncastConverter ) );
+	}
+}
 ```