@ckeditor/ckeditor5-engine
Version:
The editing engine of CKEditor 5 – the best browser-based rich text editor.
1,177 lines • 94.1 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* Contains downcast (model-to-view) converters for {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}.
*
* @module engine/conversion/downcasthelpers
*/
import { ModelRange } from '../model/range.js';
import { ModelSelection } from '../model/selection.js';
import { ModelDocumentSelection } from '../model/documentselection.js';
import { ModelElement } from '../model/element.js';
import { ModelPosition } from '../model/position.js';
import { ViewAttributeElement } from '../view/attributeelement.js';
import { ConversionHelpers } from './conversionhelpers.js';
import { StylesMap } from '../view/stylesmap.js';
import { CKEditorError, toArray } from '@ckeditor/ckeditor5-utils';
import { cloneDeep } from 'es-toolkit/compat';
/**
* Downcast conversion helper functions.
*
* Learn more about {@glink framework/deep-dive/conversion/downcast downcast helpers}.
*
* @extends module:engine/conversion/conversionhelpers~ConversionHelpers
*/
export class DowncastHelpers extends ConversionHelpers {
/**
* Model element to view element conversion helper.
*
* This conversion results in creating a view element. For example, model `<paragraph>Foo</paragraph>` becomes `<p>Foo</p>` in the view.
*
* ```ts
* editor.conversion.for( 'downcast' ).elementToElement( {
* model: 'paragraph',
* view: 'p'
* } );
*
* editor.conversion.for( 'downcast' ).elementToElement( {
* model: 'paragraph',
* view: 'div',
* converterPriority: 'high'
* } );
*
* editor.conversion.for( 'downcast' ).elementToElement( {
* model: 'fancyParagraph',
* view: {
* name: 'p',
* classes: 'fancy'
* }
* } );
*
* editor.conversion.for( 'downcast' ).elementToElement( {
* model: 'heading',
* view: ( modelElement, conversionApi ) => {
* const { writer } = conversionApi;
*
* return writer.createContainerElement( 'h' + modelElement.getAttribute( 'level' ) );
* }
* } );
* ```
*
* The element-to-element conversion supports the reconversion mechanism. It can be enabled by using either the `attributes` or
* the `children` props on a model description. You will find a couple examples below.
*
* In order to reconvert an element if any of its direct children have been added or removed, use the `children` property on a `model`
* description. For example, this model:
*
* ```xml
* <box>
* <paragraph>Some text.</paragraph>
* </box>
* ```
*
* will be converted into this structure in the view:
*
* ```html
* <div class="box" data-type="single">
* <p>Some text.</p>
* </div>
* ```
*
* But if more items were inserted in the model:
*
* ```xml
* <box>
* <paragraph>Some text.</paragraph>
* <paragraph>Other item.</paragraph>
* </box>
* ```
*
* it will be converted into this structure in the view (note the element `data-type` change):
*
* ```html
* <div class="box" data-type="multiple">
* <p>Some text.</p>
* <p>Other item.</p>
* </div>
* ```
*
* Such a converter would look like this (note that the `paragraph` elements are converted separately):
*
* ```ts
* editor.conversion.for( 'downcast' ).elementToElement( {
* model: {
* name: 'box',
* children: true
* },
* view: ( modelElement, conversionApi ) => {
* const { writer } = conversionApi;
*
* return writer.createContainerElement( 'div', {
* class: 'box',
* 'data-type': modelElement.childCount == 1 ? 'single' : 'multiple'
* } );
* }
* } );
* ```
*
* In order to reconvert element if any of its attributes have been updated, use the `attributes` property on a `model`
* description. For example, this model:
*
* ```xml
* <heading level="2">Some text.</heading>
* ```
*
* will be converted into this structure in the view:
*
* ```html
* <h2>Some text.</h2>
* ```
*
* But if the `heading` element's `level` attribute has been updated to `3` for example, then
* it will be converted into this structure in the view:
*
* ```html
* <h3>Some text.</h3>
* ```
*
* Such a converter would look as follows:
*
* ```ts
* editor.conversion.for( 'downcast' ).elementToElement( {
* model: {
* name: 'heading',
* attributes: 'level'
* },
* view: ( modelElement, conversionApi ) => {
* const { writer } = conversionApi;
*
* return writer.createContainerElement( 'h' + modelElement.getAttribute( 'level' ) );
* }
* } );
* ```
*
* See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
* to the conversion process.
*
* You can read more about the element-to-element conversion in the
* {@glink framework/deep-dive/conversion/downcast downcast conversion} guide.
*
* @param config Conversion configuration.
* @param config.model The description or a name of the model element to convert.
* @param config.view A view element definition or a function that takes the model element and
* {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API}
* as parameters and returns a view container element.
* @param config.converterPriority Converter priority.
*/
elementToElement(config) {
return this.add(downcastElementToElement(config));
}
/**
* The model element to view structure (several elements) conversion helper.
*
* This conversion results in creating a view structure with one or more slots defined for the child nodes.
* For example, a model `<table>` may become this structure in the view:
*
* ```html
* <figure class="table">
* <table>
* <tbody>${ slot for table rows }</tbody>
* </table>
* </figure>
* ```
*
* The children of the model's `<table>` element will be inserted into the `<tbody>` element.
* If the `elementToElement()` helper was used, the children would be inserted into the `<figure>`.
*
* Imagine a table feature where for this model structure:
*
* ```xml
* <table headingRows="1">
* <tableRow> ... table cells 1 ... </tableRow>
* <tableRow> ... table cells 2 ... </tableRow>
* <tableRow> ... table cells 3 ... </tableRow>
* <caption>Caption text</caption>
* </table>
* ```
*
* we want to generate this view structure:
*
* ```html
* <figure class="table">
* <table>
* <thead>
* <tr> ... table cells 1 ... </tr>
* </thead>
* <tbody>
* <tr> ... table cells 2 ... </tr>
* <tr> ... table cells 3 ... </tr>
* </tbody>
* </table>
* <figcaption>Caption text</figcaption>
* </figure>
* ```
*
* The converter has to take the `headingRows` attribute into consideration when allocating the `<tableRow>` elements
* into the `<tbody>` and `<thead>` elements. Hence, we need two slots and need to define proper filter callbacks for them.
*
* Additionally, all elements other than `<tableRow>` should be placed outside the `<table>` tag.
* In the example above, this will handle the table caption.
*
* Such a converter would look like this:
*
* ```ts
* editor.conversion.for( 'downcast' ).elementToStructure( {
* model: {
* name: 'table',
* attributes: [ 'headingRows' ]
* },
* view: ( modelElement, conversionApi ) => {
* const { writer } = conversionApi;
*
* const figureElement = writer.createContainerElement( 'figure', { class: 'table' } );
* const tableElement = writer.createContainerElement( 'table' );
*
* writer.insert( writer.createPositionAt( figureElement, 0 ), tableElement );
*
* const headingRows = modelElement.getAttribute( 'headingRows' ) || 0;
*
* if ( headingRows > 0 ) {
* const tableHead = writer.createContainerElement( 'thead' );
*
* const headSlot = writer.createSlot( node => node.is( 'element', 'tableRow' ) && node.index < headingRows );
*
* writer.insert( writer.createPositionAt( tableElement, 'end' ), tableHead );
* writer.insert( writer.createPositionAt( tableHead, 0 ), headSlot );
* }
*
* if ( headingRows < tableUtils.getRows( table ) ) {
* const tableBody = writer.createContainerElement( 'tbody' );
*
* const bodySlot = writer.createSlot( node => node.is( 'element', 'tableRow' ) && node.index >= headingRows );
*
* writer.insert( writer.createPositionAt( tableElement, 'end' ), tableBody );
* writer.insert( writer.createPositionAt( tableBody, 0 ), bodySlot );
* }
*
* const restSlot = writer.createSlot( node => !node.is( 'element', 'tableRow' ) );
*
* writer.insert( writer.createPositionAt( figureElement, 'end' ), restSlot );
*
* return figureElement;
* }
* } );
* ```
*
* Note: The children of a model element that's being converted must be allocated in the same order in the view
* in which they are placed in the model.
*
* See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
* to the conversion process.
*
* @param config Conversion configuration.
* @param config.model The description or a name of the model element to convert.
* @param config.view A function that takes the model element and
* {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} as parameters
* and returns a view container element with slots for model child nodes to be converted into.
* @param config.converterPriority Converter priority.
*/
elementToStructure(config) {
return this.add(downcastElementToStructure(config));
}
/**
* Model attribute to view element conversion helper.
*
* This conversion results in wrapping view nodes with a view attribute element. For example, a model text node with
* `"Foo"` as data and the `bold` attribute becomes `<strong>Foo</strong>` in the view.
*
* ```ts
* editor.conversion.for( 'downcast' ).attributeToElement( {
* model: 'bold',
* view: 'strong'
* } );
*
* editor.conversion.for( 'downcast' ).attributeToElement( {
* model: 'bold',
* view: 'b',
* converterPriority: 'high'
* } );
*
* editor.conversion.for( 'downcast' ).attributeToElement( {
* model: 'invert',
* view: {
* name: 'span',
* classes: [ 'font-light', 'bg-dark' ]
* }
* } );
*
* editor.conversion.for( 'downcast' ).attributeToElement( {
* model: {
* key: 'fontSize',
* values: [ 'big', 'small' ]
* },
* view: {
* big: {
* name: 'span',
* styles: {
* 'font-size': '1.2em'
* }
* },
* small: {
* name: 'span',
* styles: {
* 'font-size': '0.8em'
* }
* }
* }
* } );
*
* editor.conversion.for( 'downcast' ).attributeToElement( {
* model: 'bold',
* view: ( modelAttributeValue, conversionApi ) => {
* const { writer } = conversionApi;
*
* return writer.createAttributeElement( 'span', {
* style: 'font-weight:' + modelAttributeValue
* } );
* }
* } );
*
* editor.conversion.for( 'downcast' ).attributeToElement( {
* model: {
* key: 'color',
* name: '$text'
* },
* view: ( modelAttributeValue, conversionApi ) => {
* const { writer } = conversionApi;
*
* return writer.createAttributeElement( 'span', {
* style: 'color:' + modelAttributeValue
* } );
* }
* } );
* ```
*
* See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
* to the conversion process.
*
* @param config Conversion configuration.
* @param config.model The key of the attribute to convert from or a `{ key, values }` object. `values` is an array
* of `String`s with possible values if the model attribute is an enumerable.
* @param config.view A view element definition or a function
* that takes the model attribute value and
* {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} as parameters and returns a view
* attribute element. If `config.model.values` is given, `config.view` should be an object assigning values from `config.model.values`
* to view element definitions or functions.
* @param config.converterPriority Converter priority.
*/
attributeToElement(config) {
return this.add(downcastAttributeToElement(config));
}
/**
* Model attribute to view attribute conversion helper.
*
* This conversion results in adding an attribute to a view node, basing on an attribute from a model node. For example,
* `<imageInline src='foo.jpg'></imageInline>` is converted to `<img src='foo.jpg'></img>`.
*
* ```ts
* editor.conversion.for( 'downcast' ).attributeToAttribute( {
* model: 'source',
* view: 'src'
* } );
*
* editor.conversion.for( 'downcast' ).attributeToAttribute( {
* model: 'source',
* view: 'href',
* converterPriority: 'high'
* } );
*
* editor.conversion.for( 'downcast' ).attributeToAttribute( {
* model: {
* name: 'imageInline',
* key: 'source'
* },
* view: 'src'
* } );
*
* editor.conversion.for( 'downcast' ).attributeToAttribute( {
* model: {
* name: 'styled',
* values: [ 'dark', 'light' ]
* },
* view: {
* dark: {
* key: 'class',
* value: [ 'styled', 'styled-dark' ]
* },
* light: {
* key: 'class',
* value: [ 'styled', 'styled-light' ]
* }
* }
* } );
*
* editor.conversion.for( 'downcast' ).attributeToAttribute( {
* model: 'styled',
* view: modelAttributeValue => ( {
* key: 'class',
* value: 'styled-' + modelAttributeValue
* } )
* } );
* ```
*
* **Note**: Downcasting to a style property requires providing `value` as an object:
*
* ```ts
* editor.conversion.for( 'downcast' ).attributeToAttribute( {
* model: 'lineHeight',
* view: modelAttributeValue => ( {
* key: 'style',
* value: {
* 'line-height': modelAttributeValue,
* 'border-bottom': '1px dotted #ba2'
* }
* } )
* } );
* ```
*
* See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
* to the conversion process.
*
* @param config Conversion configuration.
* @param config.model The key of the attribute to convert from or a `{ key, values, [ name ] }` object describing
* the attribute key, possible values and, optionally, an element name to convert from.
* @param config.view A view attribute key, or a `{ key, value }` object or a function that takes the model attribute value and
* {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API}
* as parameters and returns a `{ key, value }` object. If the `key` is `'class'`, the `value` can be a `String` or an
* array of `String`s. If the `key` is `'style'`, the `value` is an object with key-value pairs. In other cases, `value` is a `String`.
* If `config.model.values` is set, `config.view` should be an object assigning values from `config.model.values` to
* `{ key, value }` objects or a functions.
* @param config.converterPriority Converter priority.
*/
attributeToAttribute(config) {
return this.add(downcastAttributeToAttribute(config));
}
/**
* Model marker to view element conversion helper.
*
* **Note**: This method should be used mainly for editing the downcast and it is recommended
* to use the {@link #markerToData `#markerToData()`} helper instead.
*
* This helper may produce invalid HTML code (e.g. a span between table cells).
* It should only be used when you are sure that the produced HTML will be semantically correct.
*
* This conversion results in creating a view element on the boundaries of the converted marker. If the converted marker
* is collapsed, only one element is created. For example, a model marker set like this: `<paragraph>F[oo b]ar</paragraph>`
* becomes `<p>F<span data-marker="search"></span>oo b<span data-marker="search"></span>ar</p>` in the view.
*
* ```ts
* editor.conversion.for( 'editingDowncast' ).markerToElement( {
* model: 'search',
* view: 'marker-search'
* } );
*
* editor.conversion.for( 'editingDowncast' ).markerToElement( {
* model: 'search',
* view: 'search-result',
* converterPriority: 'high'
* } );
*
* editor.conversion.for( 'editingDowncast' ).markerToElement( {
* model: 'search',
* view: {
* name: 'span',
* attributes: {
* 'data-marker': 'search'
* }
* }
* } );
*
* editor.conversion.for( 'editingDowncast' ).markerToElement( {
* model: 'search',
* view: ( markerData, conversionApi ) => {
* const { writer } = conversionApi;
*
* return writer.createUIElement( 'span', {
* 'data-marker': 'search',
* 'data-start': markerData.isOpening
* } );
* }
* } );
* ```
*
* If a function is passed as the `config.view` parameter, it will be used to generate both boundary elements. The function
* receives the `data` object and {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API}
* as a parameters and should return an instance of the
* {@link module:engine/view/uielement~ViewUIElement view UI element}. The `data` object and
* {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi `conversionApi`} are passed from
* {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker}. Additionally,
* the `data.isOpening` parameter is passed, which is set to `true` for the marker start boundary element, and `false` for
* the marker end boundary element.
*
* See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
* to the conversion process.
*
* @param config Conversion configuration.
* @param config.model The name of the model marker (or model marker group) to convert.
* @param config.view A view element definition or a function that takes the model marker data and
* {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} as a parameters
* and returns a view UI element.
* @param config.converterPriority Converter priority.
*/
markerToElement(config) {
return this.add(downcastMarkerToElement(config));
}
/**
* Model marker to highlight conversion helper.
*
* This conversion results in creating a highlight on view nodes. For this kind of conversion,
* the {@link module:engine/conversion/downcasthelpers~DowncastHighlightDescriptor} should be provided.
*
* For text nodes, a `<span>` {@link module:engine/view/attributeelement~ViewAttributeElement} is created and it wraps all text nodes
* in the converted marker range. For example, a model marker set like this: `<paragraph>F[oo b]ar</paragraph>` becomes
* `<p>F<span class="comment">oo b</span>ar</p>` in the view.
*
* {@link module:engine/view/containerelement~ViewContainerElement} may provide a custom way of handling highlight. Most often,
* the element itself is given classes and attributes described in the highlight descriptor (instead of being wrapped in `<span>`).
* For example, a model marker set like this:
* `[<imageInline src="foo.jpg"></imageInline>]` becomes `<img src="foo.jpg" class="comment"></img>` in the view.
*
* For container elements, the conversion is two-step. While the converter processes the highlight descriptor and passes it
* to a container element, it is the container element instance itself that applies values from the highlight descriptor.
* So, in a sense, the converter takes care of stating what should be applied on what, while the element decides how to apply that.
*
* ```ts
* editor.conversion.for( 'downcast' ).markerToHighlight( { model: 'comment', view: { classes: 'comment' } } );
*
* editor.conversion.for( 'downcast' ).markerToHighlight( {
* model: 'comment',
* view: { classes: 'comment' },
* converterPriority: 'high'
* } );
*
* editor.conversion.for( 'downcast' ).markerToHighlight( {
* model: 'comment',
* view: ( data, conversionApi ) => {
* // Assuming that the marker name is in a form of comment:commentType:commentId.
* const [ , commentType, commentId ] = data.markerName.split( ':' );
*
* return {
* classes: [ 'comment', 'comment-' + commentType ],
* attributes: { 'data-comment-id': commentId }
* };
* }
* } );
* ```
*
* If a function is passed as the `config.view` parameter, it will be used to generate the highlight descriptor. The function
* receives the `data` object and {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API}
* as the parameters and should return a
* {@link module:engine/conversion/downcasthelpers~DowncastHighlightDescriptor highlight descriptor}.
* The `data` object properties are passed from {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker}.
*
* See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
* to the conversion process.
*
* @param config Conversion configuration.
* @param config.model The name of the model marker (or model marker group) to convert.
* @param config.view A highlight descriptor that will be used for highlighting or a function that takes the model marker data and
* {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} as a parameters
* and returns a highlight descriptor.
* @param config.converterPriority Converter priority.
*/
markerToHighlight(config) {
return this.add(downcastMarkerToHighlight(config));
}
/**
* Model marker converter for data downcast.
*
* This conversion creates a representation for model marker boundaries in the view:
*
* * If the marker boundary is before or after a model element, a view attribute is set on a corresponding view element.
* * In other cases, a view element with the specified tag name is inserted at the corresponding view position.
*
* Typically, the marker names use the `group:uniqueId:otherData` convention. For example: `comment:e34zfk9k2n459df53sjl34:zx32c`.
* The default configuration for this conversion is that the first part is the `group` part and the rest of
* the marker name becomes the `name` part.
*
* Tag and attribute names and values are generated from the marker name:
*
* * The templates for attributes are `data-[group]-start-before="[name]"`, `data-[group]-start-after="[name]"`,
* `data-[group]-end-before="[name]"` and `data-[group]-end-after="[name]"`.
* * The templates for view elements are `<[group]-start name="[name]">` and `<[group]-end name="[name]">`.
*
* Attributes mark whether the given marker's start or end boundary is before or after the given element.
* The `data-[group]-start-before` and `data-[group]-end-after` attributes are favored.
* The other two are used when the former two cannot be used.
*
* The conversion configuration can take a function that will generate different group and name parts.
* If such a function is set as the `config.view` parameter, it is passed a marker name and it is expected to return an object with two
* properties: `group` and `name`. If the function returns a falsy value, the conversion will not take place.
*
* Basic usage:
*
* ```ts
* // Using the default conversion.
* // In this case, all markers with names starting with 'comment:' will be converted.
* // The `group` parameter will be set to `comment`.
* // The `name` parameter will be the rest of the marker name (without the `:`).
* editor.conversion.for( 'dataDowncast' ).markerToData( {
* model: 'comment'
* } );
* ```
*
* An example of a view that may be generated by this conversion (assuming a marker with the name `comment:commentId:uid` marked
* by `[]`):
*
* ```
* // Model:
* <paragraph>Foo[bar</paragraph>
* <imageBlock src="abc.jpg"></imageBlock>]
*
* // View:
* <p>Foo<comment-start name="commentId:uid"></comment-start>bar</p>
* <figure data-comment-end-after="commentId:uid" class="image"><img src="abc.jpg" /></figure>
* ```
*
* In the example above, the comment starts before "bar" and ends after the image.
*
* If the `name` part is empty, the following view may be generated:
*
* ```html
* <p>Foo <myMarker-start></myMarker-start>bar</p>
* <figure data-myMarker-end-after="" class="image"><img src="abc.jpg" /></figure>
* ```
*
* **Note:** A situation where some markers have the `name` part and some do not, is incorrect and should be avoided.
*
* Examples where `data-group-start-after` and `data-group-end-before` are used:
*
* ```
* // Model:
* <blockQuote>[]<paragraph>Foo</paragraph></blockQuote>
*
* // View:
* <blockquote><p data-group-end-before="name" data-group-start-before="name">Foo</p></blockquote>
* ```
*
* Similarly, when a marker is collapsed after the last element:
*
* ```
* // Model:
* <blockQuote><paragraph>Foo</paragraph>[]</blockQuote>
*
* // View:
* <blockquote><p data-group-end-after="name" data-group-start-after="name">Foo</p></blockquote>
* ```
*
* When there are multiple markers from the same group stored in the same attribute of the same element, their
* name parts are put together in the attribute value, for example: `data-group-start-before="name1,name2,name3"`.
*
* Other examples of usage:
*
* ```ts
* // Using a custom function which is the same as the default conversion:
* editor.conversion.for( 'dataDowncast' ).markerToData( {
* model: 'comment',
* view: markerName => ( {
* group: 'comment',
* name: markerName.substr( 8 ) // Removes 'comment:' part.
* } )
* } );
*
* // Using the converter priority:
* editor.conversion.for( 'dataDowncast' ).markerToData( {
* model: 'comment',
* view: markerName => ( {
* group: 'comment',
* name: markerName.substr( 8 ) // Removes 'comment:' part.
* } ),
* converterPriority: 'high'
* } );
* ```
*
* This kind of conversion is useful for saving data into the database, so it should be used in the data conversion pipeline.
*
* See the {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} API guide to learn how to
* add a converter to the conversion process.
*
* @param config Conversion configuration.
* @param config.model The name of the model marker (or the model marker group) to convert.
* @param config.view A function that takes the model marker name and
* {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} as the parameters
* and returns an object with the `group` and `name` properties.
* @param config.converterPriority Converter priority.
*/
markerToData(config) {
return this.add(downcastMarkerToData(config));
}
}
/**
* Function factory that creates a default downcast converter for text insertion changes.
*
* The converter automatically consumes the corresponding value from the consumables list and stops the event (see
* {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}).
*
* ```ts
* modelDispatcher.on( 'insert:$text', insertText() );
* ```
*
* @returns Insert text event converter.
* @internal
*/
export function insertText() {
return (evt, data, conversionApi) => {
if (!conversionApi.consumable.consume(data.item, evt.name)) {
return;
}
const viewWriter = conversionApi.writer;
const viewPosition = conversionApi.mapper.toViewPosition(data.range.start);
const viewText = viewWriter.createText(data.item.data);
viewWriter.insert(viewPosition, viewText);
};
}
/**
* Function factory that creates a default downcast converter for triggering attributes and children conversion.
*
* @returns The converter.
* @internal
*/
export function insertAttributesAndChildren() {
return (evt, data, conversionApi) => {
conversionApi.convertAttributes(data.item);
// Start converting children of the current item.
// In case of reconversion children were already re-inserted or converted separately.
if (!data.reconversion && data.item.is('element') && !data.item.isEmpty) {
conversionApi.convertChildren(data.item);
}
};
}
/**
* Function factory that creates a default downcast converter for node remove changes.
*
* ```ts
* modelDispatcher.on( 'remove', remove() );
* ```
*
* @returns Remove event converter.
* @internal
*/
export function remove() {
return (evt, data, conversionApi) => {
// Find the view range start position by mapping the model position at which the remove happened.
const viewStart = conversionApi.mapper.toViewPosition(data.position);
const modelEnd = data.position.getShiftedBy(data.length);
const viewEnd = conversionApi.mapper.toViewPosition(modelEnd, { isPhantom: true });
const viewRange = conversionApi.writer.createRange(viewStart, viewEnd);
// Trim the range to remove in case some UI elements are on the view range boundaries.
const removed = conversionApi.writer.remove(viewRange.getTrimmed());
// After the range is removed, unbind all view elements from the model.
// Range inside view document fragment is used to unbind deeply.
for (const child of conversionApi.writer.createRangeIn(removed).getItems()) {
conversionApi.mapper.unbindViewElement(child, { defer: true });
}
};
}
/**
* Creates a `<span>` {@link module:engine/view/attributeelement~ViewAttributeElement view attribute element} from the information
* provided by the {@link module:engine/conversion/downcasthelpers~DowncastHighlightDescriptor highlight descriptor} object. If the priority
* is not provided in the descriptor, the default priority will be used.
*
* @internal
*/
export function createViewElementFromDowncastHighlightDescriptor(writer, descriptor) {
const viewElement = writer.createAttributeElement('span', descriptor.attributes);
if (descriptor.classes) {
viewElement._addClass(descriptor.classes);
}
if (typeof descriptor.priority === 'number') {
viewElement._priority = descriptor.priority;
}
viewElement._id = descriptor.id;
return viewElement;
}
/**
* Function factory that creates a converter which converts a non-collapsed
* {@link module:engine/model/selection~ModelSelection model selection}
* to a {@link module:engine/view/documentselection~ViewDocumentSelection view selection}. The converter consumes appropriate
* value from the `consumable` object and maps model positions from the selection to view positions.
*
* ```ts
* modelDispatcher.on( 'selection', convertRangeSelection() );
* ```
*
* @returns Selection converter.
* @internal
*/
export function convertRangeSelection() {
return (evt, data, conversionApi) => {
const selection = data.selection;
if (selection.isCollapsed) {
return;
}
if (!conversionApi.consumable.consume(selection, 'selection')) {
return;
}
const viewRanges = [];
for (const range of selection.getRanges()) {
viewRanges.push(conversionApi.mapper.toViewRange(range));
}
conversionApi.writer.setSelection(viewRanges, { backward: selection.isBackward });
};
}
/**
* Function factory that creates a converter which converts a collapsed
* {@link module:engine/model/selection~ModelSelection model selection} to
* a {@link module:engine/view/documentselection~ViewDocumentSelection view selection}. The converter consumes appropriate
* value from the `consumable` object, maps the model selection position to the view position and breaks
* {@link module:engine/view/attributeelement~ViewAttributeElement attribute elements} at the selection position.
*
* ```ts
* modelDispatcher.on( 'selection', convertCollapsedSelection() );
* ```
*
* An example of the view state before and after converting the collapsed selection:
*
* ```
* <p><strong>f^oo<strong>bar</p>
* -> <p><strong>f</strong>^<strong>oo</strong>bar</p>
* ```
*
* By breaking attribute elements like `<strong>`, the selection is in a correct element. Then, when the selection attribute is
* converted, broken attributes might be merged again, or the position where the selection is may be wrapped
* with different, appropriate attribute elements.
*
* See also {@link module:engine/conversion/downcasthelpers~cleanSelection} which does a clean-up
* by merging attributes.
*
* @returns Selection converter.
* @internal
*/
export function convertCollapsedSelection() {
return (evt, data, conversionApi) => {
const selection = data.selection;
if (!selection.isCollapsed) {
return;
}
if (!conversionApi.consumable.consume(selection, 'selection')) {
return;
}
const viewWriter = conversionApi.writer;
const modelPosition = selection.getFirstPosition();
const viewPosition = conversionApi.mapper.toViewPosition(modelPosition);
const brokenPosition = viewWriter.breakAttributes(viewPosition);
viewWriter.setSelection(brokenPosition);
};
}
/**
* Function factory that creates a converter which cleans artifacts after the previous
* {@link module:engine/model/selection~ModelSelection model selection} conversion. It removes all empty
* {@link module:engine/view/attributeelement~ViewAttributeElement view attribute elements} and merges
* sibling attributes at all start and end positions of all ranges.
*
* ```
* <p><strong>^</strong></p>
* -> <p>^</p>
*
* <p><strong>foo</strong>^<strong>bar</strong>bar</p>
* -> <p><strong>foo^bar<strong>bar</p>
*
* <p><strong>foo</strong><em>^</em><strong>bar</strong>bar</p>
* -> <p><strong>foo^bar<strong>bar</p>
* ```
*
* This listener should be assigned before any converter for the new selection:
*
* ```ts
* modelDispatcher.on( 'cleanSelection', cleanSelection() );
* ```
*
* See {@link module:engine/conversion/downcasthelpers~convertCollapsedSelection}
* which does the opposite by breaking attributes in the selection position.
*
* @returns Selection converter.
* @internal
*/
export function cleanSelection() {
return (evt, data, conversionApi) => {
const viewWriter = conversionApi.writer;
const viewSelection = viewWriter.document.selection;
for (const range of viewSelection.getRanges()) {
// Not collapsed selection should not have artifacts.
if (range.isCollapsed) {
// Position might be in the node removed by the view writer.
if (range.end.parent.isAttached()) {
conversionApi.writer.mergeAttributes(range.start);
}
}
}
viewWriter.setSelection(null);
};
}
/**
* Function factory that creates a converter which converts the set/change/remove attribute changes from the model to the view.
* It can also be used to convert selection attributes. In that case, an empty attribute element will be created and the
* selection will be put inside it.
*
* Attributes from the model are converted to a view element that will be wrapping these view nodes that are bound to
* model elements having the given attribute. This is useful for attributes like `bold` that may be set on text nodes in the model
* but are represented as an element in the view:
*
* ```
* [paragraph] MODEL ====> VIEW <p>
* |- a {bold: true} |- <b>
* |- b {bold: true} | |- ab
* |- c |- c
* ```
*
* Passed `Function` will be provided with the attribute value and then all the parameters of the
* {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute `attribute` event}.
* It is expected that the function returns an {@link module:engine/view/element~ViewElement}.
* The result of the function will be the wrapping element.
* When the provided `Function` does not return any element, no conversion will take place.
*
* The converter automatically consumes the corresponding value from the consumables list and stops the event (see
* {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}).
*
* ```ts
* modelDispatcher.on( 'attribute:bold', wrap( ( modelAttributeValue, { writer } ) => {
* return writer.createAttributeElement( 'strong' );
* } );
* ```
*
* @internal
* @param elementCreator Function returning a view element that will be used for wrapping.
* @returns Set/change attribute converter.
*/
export function wrap(elementCreator) {
return (evt, data, conversionApi) => {
if (!conversionApi.consumable.test(data.item, evt.name)) {
return;
}
// Recreate current wrapping node. It will be used to unwrap view range if the attribute value has changed
// or the attribute was removed.
const oldViewElement = elementCreator(data.attributeOldValue, conversionApi, data);
// Create node to wrap with.
const newViewElement = elementCreator(data.attributeNewValue, conversionApi, data);
if (!oldViewElement && !newViewElement) {
return;
}
conversionApi.consumable.consume(data.item, evt.name);
const viewWriter = conversionApi.writer;
const viewSelection = viewWriter.document.selection;
if (data.item instanceof ModelSelection || data.item instanceof ModelDocumentSelection) {
// Selection attribute conversion.
viewWriter.wrap(viewSelection.getFirstRange(), newViewElement);
}
else {
// Node attribute conversion.
let viewRange = conversionApi.mapper.toViewRange(data.range);
// First, unwrap the range from current wrapper.
if (data.attributeOldValue !== null && oldViewElement) {
viewRange = viewWriter.unwrap(viewRange, oldViewElement);
}
if (data.attributeNewValue !== null && newViewElement) {
viewWriter.wrap(viewRange, newViewElement);
}
}
};
}
/**
* Function factory that creates a converter which converts node insertion changes from the model to the view.
* The function passed will be provided with all the parameters of the dispatcher's
* {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert `insert` event}.
* It is expected that the function returns an {@link module:engine/view/element~ViewElement}.
* The result of the function will be inserted into the view.
*
* The converter automatically consumes the corresponding value from the consumables list and binds the model and view elements.
*
* ```ts
* downcastDispatcher.on(
* 'insert:myElem',
* insertElement( ( modelItem, { writer } ) => {
* const text = writer.createText( 'myText' );
* const myElem = writer.createElement( 'myElem', { myAttr: 'my-' + modelItem.getAttribute( 'myAttr' ) }, text );
*
* // Do something fancy with `myElem` using `modelItem` or other parameters.
*
* return myElem;
* }
* ) );
* ```
*
* @internal
* @param elementCreator Function returning a view element, which will be inserted.
* @param consumer Function defining element consumption process.
* By default this function just consume passed item insertion.
* @returns Insert element event converter.
*/
export function insertElement(elementCreator, consumer = defaultConsumer) {
return (evt, data, conversionApi) => {
if (!consumer(data.item, conversionApi.consumable, { preflight: true })) {
return;
}
const viewElement = elementCreator(data.item, conversionApi, data);
if (!viewElement) {
return;
}
// Consume an element insertion and all present attributes that are specified as a reconversion triggers.
consumer(data.item, conversionApi.consumable);
const viewPosition = conversionApi.mapper.toViewPosition(data.range.start);
conversionApi.mapper.bindElements(data.item, viewElement);
conversionApi.writer.insert(viewPosition, viewElement);
// Convert attributes before converting children.
conversionApi.convertAttributes(data.item);
// Convert children or reinsert previous view elements.
reinsertOrConvertNodes(viewElement, data.item.getChildren(), conversionApi, { reconversion: data.reconversion });
};
}
/**
* Function factory that creates a converter which converts a single model node insertion to a view structure.
*
* It is expected that the passed element creator function returns an {@link module:engine/view/element~ViewElement} with attached slots
* created with `writer.createSlot()` to indicate where child nodes should be converted.
*
* @see module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure
*
* @internal
* @param elementCreator Function returning a view structure, which will be inserted.
* @param consumer A callback that is expected to consume all the consumables
* that were used by the element creator.
* @returns Insert element event converter.
*/
export function insertStructure(elementCreator, consumer) {
return (evt, data, conversionApi) => {
if (!consumer(data.item, conversionApi.consumable, { preflight: true })) {
return;
}
const slotsMap = new Map();
conversionApi.writer._registerSlotFactory(createSlotFactory(data.item, slotsMap, conversionApi));
// View creation.
const viewElement = elementCreator(data.item, conversionApi, data);
conversionApi.writer._clearSlotFactory();
if (!viewElement) {
return;
}
// Check if all children are covered by slots and there is no child that landed in multiple slots.
validateSlotsChildren(data.item, slotsMap, conversionApi);
// Consume an element insertion and all present attributes that are specified as a reconversion triggers.
consumer(data.item, conversionApi.consumable);
const viewPosition = conversionApi.mapper.toViewPosition(data.range.start);
conversionApi.mapper.bindElements(data.item, viewElement);
conversionApi.writer.insert(viewPosition, viewElement);
// Convert attributes before converting children.
conversionApi.convertAttributes(data.item);
// Fill view slots with previous view elements or create new ones.
fillSlots(viewElement, slotsMap, conversionApi, { reconversion: data.reconversion });
};
}
/**
* Function factory that creates a converter which converts marker adding change to the
* {@link module:engine/view/uielement~ViewUIElement view UI element}.
*
* The view UI element that will be added to the view depends on the passed parameter. See {@link ~insertElement}.
* In case of a non-collapsed range, the UI element will not wrap nodes but separate elements will be placed at the beginning
* and at the end of the range.
*
* This converter binds created UI elements with the marker name using {@link module:engine/conversion/mapper~Mapper#bindElementToMarker}.
*
* @internal
* @param elementCreator A view UI element or a function returning the view element that will be inserted.
* @returns Insert element event converter.
*/
export function insertUIElement(elementCreator) {
return (evt, data, conversionApi) => {
// Create two view elements. One will be inserted at the beginning of marker, one at the end.
// If marker is collapsed, only "opening" element will be inserted.
data.isOpening = true;
const viewStartElement = elementCreator(data, conversionApi);
data.isOpening = false;
const viewEndElement = elementCreator(data, conversionApi);
if (!viewStartElement || !viewEndElement) {
return;
}
const markerRange = data.markerRange;
// Marker that is collapsed has consumable build differently that non-collapsed one.
// For more information see `addMarker` event description.
// If marker's range is collapsed - check if it can be consumed.
if (markerRange.isCollapsed && !conversionApi.consumable.consume(markerRange, evt.name)) {
return;
}
// If marker's range is not collapsed - consume all items inside.
for (const value of markerRange) {
if (!conversionApi.consumable.consume(value.item, evt.name)) {
return;
}
}
const mapper = conversionApi.mapper;
const viewWriter = conversionApi.writer;
// Add "opening" element.
viewWriter.insert(mapper.toViewPosition(markerRange.start), viewStartElement);
conversionApi.mapper.bindElementToMarker(viewStartElement, data.markerName);
// Add "closing" element only if range is not collapsed.
if (!markerRange.isCollapsed) {
viewWriter.insert(mapper.toViewPosition(markerRange.end), viewEndElement);
conversionApi.mapper.bindElementToMarker(viewEndElement, data.markerName);
}
evt.stop();
};
}
/**
* Function factory that returns a default downcast converter for removing a {@link module:engine/view/uielement~ViewUIElement UI element}
* based on marker remove change.
*
* This converter unbinds elements from the marker name.
*
* @returns Removed UI element converter.
*/
function removeUIElement() {
return (evt, data, conversionApi) => {
const elements = conversionApi.mapper.markerNameToElements(data.markerName);
if (!elements) {
return;
}
for (const element of elements) {
conversionApi.mapper.unbindElementFromMarkerName(element, data.markerName);
conversionApi.writer.clear(conversionApi.writer.createRangeOn(element), element);
}
conversionApi.writer.clearClonedElementsGroup(data.markerName);
evt.stop();
};
}
/**
* Function factory that creates a default converter for model markers.
*
* See {@link DowncastHelpers#markerToData} for more information what type of view is generated.
*
* This converter binds created UI elements and affected view elements with the marker name
* using {@link module:engine/conversion/mapper~Mapper#bindElementToMarker}.
*
* @returns Add marker converter.
*/
function insertMarkerData(viewCreator) {
return (evt, data, conversionApi) => {
const viewMarkerData = viewCreator(data.markerName, conversionApi);
if (!viewMarkerData) {
return;
}
const markerRange = data.markerRange;
if (!conversionApi.consumable.consume(markerRange, evt.name)) {
return;
}
// Adding closing data first to keep the proper order in the view.
handleMarkerBoundary(markerRange, false, conversionApi, data, viewMarkerData);
handleMarkerBoundary(markerRange, true, conversionApi, data, viewMarkerData);
evt.stop();
};
}
/**
* Helper function for `insertMarkerData()` that marks a marker boundary at the beginning or end of given `range`.
*/
function handleMarkerBoundary(range, isStart, conversionApi, data, viewMarkerData) {
const modelPosition = isStart ? range.start : range.end;
const elementAfter = modelPosition.nodeAfter && modelPosition.nodeAfter.is('element') ? modelPosition.nodeAfter : null;
const elementBefore = modelPosition.nodeBefore && modelPosition.nodeBefore.is('element') ? modelPosition.nodeBefore : null;
if (elementAfter |