diff --git a/src/controller/datacontroller.js b/src/controller/datacontroller.js
index 695ade9cf..5ecbe0e3a 100644
--- a/src/controller/datacontroller.js
+++ b/src/controller/datacontroller.js
@@ -167,7 +167,7 @@ export default class DataController {
const viewDocumentFragment = new ViewDocumentFragment();
this.mapper.bindElements( modelElementOrFragment, viewDocumentFragment );
- this.modelToView.convertInsertion( modelRange );
+ this.modelToView.convertInsert( modelRange );
this.mapper.clearBindings();
diff --git a/src/controller/editingcontroller.js b/src/controller/editingcontroller.js
index de93e7970..3b82c690a 100644
--- a/src/controller/editingcontroller.js
+++ b/src/controller/editingcontroller.js
@@ -7,6 +7,7 @@
* @module engine/controller/editingcontroller
*/
+import ModelDiffer from '../model/differ';
import ViewDocument from '../view/document';
import Mapper from '../conversion/mapper';
import ModelConversionDispatcher from '../conversion/modelconversiondispatcher';
@@ -64,10 +65,9 @@ export default class EditingController {
this.mapper = new Mapper();
/**
- * Model to view conversion dispatcher, which converts changes from the model to
- * {@link #view editing view}.
+ * Model to view conversion dispatcher, which converts changes from the model to {@link #view the editing view}.
*
- * To attach model to view converter to the editing pipeline you need to add lister to this property:
+ * To attach model-to-view converter to the editing pipeline you need to add a listener to this dispatcher:
*
* editing.modelToView( 'insert:$element', customInsertConverter );
*
@@ -83,36 +83,60 @@ export default class EditingController {
viewSelection: this.view.selection
} );
- // Convert changes in model to view.
- this.listenTo( this.model.document, 'change', ( evt, type, changes ) => {
- this.modelToView.convertChange( type, changes );
- }, { priority: 'low' } );
+ // Model differ object. It's role is to buffer changes done on model and then calculates a diff of those changes.
+ // The diff is then passed to model conversion dispatcher which generates proper events and kicks-off conversion.
+ const modelDiffer = new ModelDiffer();
- // Convert model selection to view.
- this.listenTo( this.model.document, 'changesDone', () => {
- const selection = this.model.document.selection;
+ // Before an operation is applied on model, buffer the change in differ.
+ this.listenTo( this.model, 'applyOperation', ( evt, args ) => {
+ const operation = args[ 0 ];
- this.modelToView.convertSelection( selection );
- this.view.render();
- }, { priority: 'low' } );
+ if ( operation.isDocumentOperation ) {
+ modelDiffer.bufferOperation( operation );
+ }
+ }, { priority: 'high' } );
- // Convert model markers changes.
+ // Buffer marker changes.
+ // This is not covered in buffering operations because markers may change outside of them (when they
+ // are modified using `model.document.markers` collection, not through `MarkerOperation`).
this.listenTo( this.model.markers, 'add', ( evt, marker ) => {
- this.modelToView.convertMarker( 'addMarker', marker.name, marker.getRange() );
+ // Whenever a new marker is added, buffer that change.
+ modelDiffer.bufferMarkerChange( marker.name, null, marker.getRange() );
+
+ // Whenever marker changes, buffer that.
+ marker.on( 'change', ( evt, oldRange ) => {
+ modelDiffer.bufferMarkerChange( marker.name, oldRange, marker.getRange() );
+ } );
} );
this.listenTo( this.model.markers, 'remove', ( evt, marker ) => {
- this.modelToView.convertMarker( 'removeMarker', marker.name, marker.getRange() );
+ // Whenever marker is removed, buffer that change.
+ modelDiffer.bufferMarkerChange( marker.name, marker.getRange(), null );
} );
- // Convert view selection to model.
+ // When all changes are done, get the model diff containing all the changes and convert them to view and then render to DOM.
+ this.listenTo( this.model, 'changesDone', () => {
+ // Convert changes stored in `modelDiffer`.
+ this.modelToView.convertChanges( modelDiffer );
+
+ // Reset model diff object. When next operation is applied, new diff will be created.
+ modelDiffer.reset();
+
+ // After the view is ready, convert selection from model to view.
+ this.modelToView.convertSelection( this.model.document.selection );
+
+ // When everything is converted to the view, render it to DOM.
+ this.view.render();
+ }, { priority: 'low' } );
+
+ // Convert selection from view to model.
this.listenTo( this.view, 'selectionChange', convertSelectionChange( this.model, this.mapper ) );
- // Attach default content converters.
+ // Attach default model converters.
this.modelToView.on( 'insert:$text', insertText(), { priority: 'lowest' } );
this.modelToView.on( 'remove', remove(), { priority: 'low' } );
- // Attach default selection converters.
+ // Attach default model selection converters.
this.modelToView.on( 'selection', clearAttributes(), { priority: 'low' } );
this.modelToView.on( 'selection', clearFakeSelection(), { priority: 'low' } );
this.modelToView.on( 'selection', convertRangeSelection(), { priority: 'low' } );
diff --git a/src/conversion/buildmodelconverter.js b/src/conversion/buildmodelconverter.js
index bbab8af97..625ecfae1 100644
--- a/src/conversion/buildmodelconverter.js
+++ b/src/conversion/buildmodelconverter.js
@@ -10,13 +10,12 @@
import {
insertElement,
insertUIElement,
- setAttribute,
- removeAttribute,
removeUIElement,
- wrapItem,
- unwrapItem,
+ changeAttribute,
+ wrap,
highlightText,
- highlightElement
+ highlightElement,
+ removeHighlight
} from './model-to-view-converters';
import { convertSelectionAttribute, convertSelectionMarker } from './model-selection-to-view-converters';
@@ -254,15 +253,13 @@ class ModelConverterBuilder {
dispatcher.on( 'insert:' + this._from.name, insertElement( element ), { priority } );
} else if ( this._from.type == 'attribute' ) {
- // From model attribute to view element -> wrap and unwrap.
+ // From model attribute to view element -> wrap.
element = typeof element == 'string' ? new ViewAttributeElement( element ) : element;
- dispatcher.on( 'addAttribute:' + this._from.key, wrapItem( element ), { priority } );
- dispatcher.on( 'changeAttribute:' + this._from.key, wrapItem( element ), { priority } );
- dispatcher.on( 'removeAttribute:' + this._from.key, unwrapItem( element ), { priority } );
-
+ dispatcher.on( 'attribute:' + this._from.key, wrap( element ), { priority } );
dispatcher.on( 'selectionAttribute:' + this._from.key, convertSelectionAttribute( element ), { priority } );
- } else { // From marker to element.
+ } else {
+ // From marker to element.
const priority = this._from.priority === null ? 'normal' : this._from.priority;
element = typeof element == 'string' ? new ViewUIElement( element ) : element;
@@ -326,12 +323,10 @@ class ModelConverterBuilder {
}
for ( const dispatcher of this._dispatchers ) {
- // Separate converters for converting texts and elements inside marker's range.
dispatcher.on( 'addMarker:' + this._from.name, highlightText( highlightDescriptor ), { priority } );
dispatcher.on( 'addMarker:' + this._from.name, highlightElement( highlightDescriptor ), { priority } );
- dispatcher.on( 'removeMarker:' + this._from.name, highlightText( highlightDescriptor ), { priority } );
- dispatcher.on( 'removeMarker:' + this._from.name, highlightElement( highlightDescriptor ), { priority } );
+ dispatcher.on( 'removeMarker:' + this._from.name, removeHighlight( highlightDescriptor ), { priority } );
dispatcher.on( 'selectionMarker:' + this._from.name, convertSelectionMarker( highlightDescriptor ), { priority } );
}
@@ -383,7 +378,7 @@ class ModelConverterBuilder {
if ( !keyOrCreator ) {
// If `keyOrCreator` is not set, we assume default behavior which is 1:1 attribute re-write.
- // This is also a default behavior for `setAttribute` converter when no attribute creator is passed.
+ // This is also a default behavior for `changeAttribute` converter when no attribute creator is passed.
attributeCreator = undefined;
} else if ( typeof keyOrCreator == 'string' ) {
// `keyOrCreator` is an attribute key.
@@ -407,9 +402,7 @@ class ModelConverterBuilder {
for ( const dispatcher of this._dispatchers ) {
const options = { priority: this._from.priority || 'normal' };
- dispatcher.on( 'addAttribute:' + this._from.key, setAttribute( attributeCreator ), options );
- dispatcher.on( 'changeAttribute:' + this._from.key, setAttribute( attributeCreator ), options );
- dispatcher.on( 'removeAttribute:' + this._from.key, removeAttribute( attributeCreator ), options );
+ dispatcher.on( 'attribute:' + this._from.key, changeAttribute( attributeCreator ), options );
}
}
}
diff --git a/src/conversion/mapper.js b/src/conversion/mapper.js
index 37017d5c5..afad5f3aa 100644
--- a/src/conversion/mapper.js
+++ b/src/conversion/mapper.js
@@ -110,23 +110,43 @@ export default class Mapper {
/**
* Unbinds given {@link module:engine/view/element~Element view element} from the map.
*
+ * **Note:** view-to-model binding will be removed, if it existed. However, corresponding model-to-view binding
+ * will be removed only if model element is still bound to passed `viewElement`.
+ *
+ * This behavior lets for re-binding model element to another view element without fear of losing the new binding
+ * when the previously bound view element is unbound.
+ *
* @param {module:engine/view/element~Element} viewElement View element to unbind.
*/
unbindViewElement( viewElement ) {
const modelElement = this.toModelElement( viewElement );
- this._unbindElements( modelElement, viewElement );
+ this._viewToModelMapping.delete( viewElement );
+
+ if ( this._modelToViewMapping.get( modelElement ) == viewElement ) {
+ this._modelToViewMapping.delete( modelElement );
+ }
}
/**
* Unbinds given {@link module:engine/model/element~Element model element} from the map.
*
+ * **Note:** model-to-view binding will be removed, if it existed. However, corresponding view-to-model binding
+ * will be removed only if view element is still bound to passed `modelElement`.
+ *
+ * This behavior lets for re-binding view element to another model element without fear of losing the new binding
+ * when the previously bound model element is unbound.
+ *
* @param {module:engine/model/element~Element} modelElement Model element to unbind.
*/
unbindModelElement( modelElement ) {
const viewElement = this.toViewElement( modelElement );
- this._unbindElements( modelElement, viewElement );
+ this._modelToViewMapping.delete( modelElement );
+
+ if ( this._viewToModelMapping.get( viewElement ) == modelElement ) {
+ this._viewToModelMapping.delete( viewElement );
+ }
}
/**
@@ -202,12 +222,16 @@ export default class Mapper {
*
* @fires modelToViewPosition
* @param {module:engine/model/position~Position} modelPosition Model position.
+ * @param {Object} [options] Additional options for position mapping process.
+ * @param {Boolean} [options.isPhantom=false] Should be set to `true` if the model position to map is pointing to a place
+ * in model tree which no longer exists. For example, it could be an end of a removed model range.
* @returns {module:engine/view/position~Position} Corresponding view position.
*/
- toViewPosition( modelPosition ) {
+ toViewPosition( modelPosition, options = { isPhantom: false } ) {
const data = {
modelPosition,
- mapper: this
+ mapper: this,
+ isPhantom: options.isPhantom
};
this.fire( 'modelToViewPosition', data );
@@ -292,18 +316,6 @@ export default class Mapper {
return modelOffset;
}
- /**
- * Removes binding between given elements.
- *
- * @private
- * @param {module:engine/model/element~Element} modelElement Model element to unbind.
- * @param {module:engine/view/element~Element} viewElement View element to unbind.
- */
- _unbindElements( modelElement, viewElement ) {
- this._viewToModelMapping.delete( viewElement );
- this._modelToViewMapping.delete( modelElement );
- }
-
/**
* Gets the length of the view element in the model.
*
@@ -460,19 +472,33 @@ export default class Mapper {
* }
* } );
*
- * **Note:** keep in mind that custom callback provided for this event should use provided `data.modelPosition` only to check
- * what is before the position (or position's parent). This is important when model-to-view position mapping is used in
- * remove change conversion. Model after the removed position (that is being mapped) does not correspond to view, so it cannot be used:
+ * **Note:** keep in mind that sometimes a "phantom" model position is being converted. "Phantom" model position is
+ * a position that points to a non-existing place in model. Such position might still be valid for conversion, though
+ * (it would point to a correct place in view when converted). One example of such situation is when a range is
+ * removed from model, there may be a need to map the range's end (which is no longer valid model position). To
+ * handle such situation, check `data.isPhantom` flag:
+ *
+ * // Assume that there is "customElement" model element and whenever position is before it, we want to move it
+ * // to the inside of the view element bound to "customElement".
+ * mapper.on( 'modelToViewPosition', ( evt, data ) => {
+ * if ( data.isPhantom ) {
+ * return;
+ * }
*
- * // Incorrect:
- * const modelElement = data.modelPosition.nodeAfter;
- * const viewElement = data.mapper.toViewElement( modelElement );
- * // ... Do something with `viewElement` and set `data.viewPosition`.
+ * // Below line might crash for phantom position that does not exist in model.
+ * const sibling = data.modelPosition.nodeBefore;
+ *
+ * // Check if this is the element we are interested in.
+ * if ( !sibling.is( 'customElement' ) ) {
+ * return;
+ * }
*
- * // Correct:
- * const prevModelElement = data.modelPosition.nodeBefore;
- * const prevViewElement = data.mapper.toViewElement( prevModelElement );
- * // ... Use `prevViewElement` to find correct `data.viewPosition`.
+ * const viewElement = data.mapper.toViewElement( sibling );
+ *
+ * data.viewPosition = new ViewPosition( sibling, 0 );
+ *
+ * evt.stop();
+ * } );
*
* **Note:** default mapping callback is provided with `low` priority setting and does not cancel the event, so it is possible to
* attach a custom callback after default callback and also use `data.viewPosition` calculated by default callback
diff --git a/src/conversion/model-to-view-converters.js b/src/conversion/model-to-view-converters.js
index 411944808..0dd8e9aa7 100644
--- a/src/conversion/model-to-view-converters.js
+++ b/src/conversion/model-to-view-converters.js
@@ -3,17 +3,16 @@
* For licensing, see LICENSE.md.
*/
+import ModelRange from '../model/range';
+
import ViewElement from '../view/element';
import ViewAttributeElement from '../view/attributeelement';
import ViewText from '../view/text';
import ViewRange from '../view/range';
-import ViewPosition from '../view/position';
-import ViewTreeWalker from '../view/treewalker';
import viewWriter from '../view/writer';
-import ModelRange from '../model/range';
/**
- * Contains {@link module:engine/model/model model} to {@link module:engine/view/view view} converters for
+ * Contains model to view converters for
* {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}.
*
* @module engine/conversion/model-to-view-converters
@@ -91,6 +90,34 @@ export function insertText() {
};
}
+/**
+ * Function factory, creates a default model-to-view converter for node remove changes.
+ *
+ * modelDispatcher.on( 'remove', remove() );
+ *
+ * @returns {Function} Remove event converter.
+ */
+export function remove() {
+ return ( evt, data, conversionApi ) => {
+ // Find view range start position by mapping 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 = new ViewRange( viewStart, viewEnd );
+
+ // Trim the range to remove in case some UI elements are on the view range boundaries.
+ const removed = viewWriter.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 ViewRange.createIn( removed ).getItems() ) {
+ conversionApi.mapper.unbindViewElement( child );
+ }
+ };
+}
+
/**
* Function factory, creates a converter that converts marker adding change to the view ui element.
* The view ui element that will be added to the view depends on passed parameter. See {@link ~insertElement}.
@@ -108,15 +135,17 @@ export function insertUIElement( elementCreator ) {
return ( evt, data, consumable, conversionApi ) => {
let viewStartElement, viewEndElement;
+ // 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.
if ( elementCreator instanceof ViewElement ) {
viewStartElement = elementCreator.clone( true );
viewEndElement = elementCreator.clone( true );
} else {
data.isOpening = true;
- viewStartElement = elementCreator( data, consumable, conversionApi );
+ viewStartElement = elementCreator( data, conversionApi );
data.isOpening = false;
- viewEndElement = elementCreator( data, consumable, conversionApi );
+ viewEndElement = elementCreator( data, conversionApi );
}
if ( !viewStartElement || !viewEndElement ) {
@@ -127,7 +156,7 @@ export function insertUIElement( elementCreator ) {
const eventName = evt.name;
// Marker that is collapsed has consumable build differently that non-collapsed one.
- // For more information see `addMarker` and `removeMarker` events description.
+ // For more information see `addMarker` event description.
// If marker's range is collapsed - check if it can be consumed.
if ( markerRange.isCollapsed && !consumable.consume( markerRange, eventName ) ) {
return;
@@ -142,73 +171,75 @@ export function insertUIElement( elementCreator ) {
const mapper = conversionApi.mapper;
+ // Add "opening" element.
viewWriter.insert( mapper.toViewPosition( markerRange.start ), viewStartElement );
+ // Add "closing" element only if range is not collapsed.
if ( !markerRange.isCollapsed ) {
viewWriter.insert( mapper.toViewPosition( markerRange.end ), viewEndElement );
}
+
+ evt.stop();
};
}
/**
- * Function factory, creates a converter that converts set/change attribute changes from the model to the view. Attributes
- * from model are converted to the view element attributes in the view. You may provide a custom function to generate a
- * key-value attribute pair to add/change. If not provided, model attributes will be converted to view elements attributes
- * on 1-to-1 basis.
- *
- * **Note:** Provided attribute creator should always return the same `key` for given attribute from the model.
- *
- * The converter automatically consumes corresponding value from consumables list and stops the event (see
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}).
- *
- * modelDispatcher.on( 'addAttribute:customAttr:myElem', setAttribute( ( data ) => {
- * // Change attribute key from `customAttr` to `class` in view.
- * const key = 'class';
- * let value = data.attributeNewValue;
- *
- * // Force attribute value to 'empty' if the model element is empty.
- * if ( data.item.childCount === 0 ) {
- * value = 'empty';
- * }
- *
- * // Return key-value pair.
- * return { key, value };
- * } ) );
+ * Function factory, creates a default model-to-view converter for removing {@link module:engine/view/uielement~UIElement ui element}
+ * basing on marker remove change.
*
- * @param {Function} [attributeCreator] Function returning an object with two properties: `key` and `value`, which
- * represents attribute key and attribute value to be set on a {@link module:engine/view/element~Element view element}.
- * The function is passed all the parameters of the
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addAttribute}
- * or {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:changeAttribute} event.
- * @returns {Function} Set/change attribute converter.
+ * @param {module:engine/view/uielement~UIElement|Function} elementCreator View ui element, or function returning
+ * a view ui element, which will be used as a pattern when look for element to remove at the marker start position.
+ * @returns {Function} Remove ui element converter.
*/
-export function setAttribute( attributeCreator ) {
- attributeCreator = attributeCreator || ( ( value, key ) => ( { value, key } ) );
+export function removeUIElement( elementCreator ) {
+ return ( evt, data, conversionApi ) => {
+ let viewStartElement, viewEndElement;
- return ( evt, data, consumable, conversionApi ) => {
- if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
+ // Create two view elements. One will be used to remove "opening element", the other for "closing element".
+ // If marker was collapsed, only "opening" element will be removed.
+ if ( elementCreator instanceof ViewElement ) {
+ viewStartElement = elementCreator.clone( true );
+ viewEndElement = elementCreator.clone( true );
+ } else {
+ data.isOpening = true;
+ viewStartElement = elementCreator( data, conversionApi );
+
+ data.isOpening = false;
+ viewEndElement = elementCreator( data, conversionApi );
+ }
+
+ if ( !viewStartElement || !viewEndElement ) {
return;
}
- const { key, value } = attributeCreator( data.attributeNewValue, data.attributeKey, data, consumable, conversionApi );
+ const markerRange = data.markerRange;
- conversionApi.mapper.toViewElement( data.item ).setAttribute( key, value );
+ // When removing the ui elements, we map the model range to view twice, because that view range
+ // may change after the first clearing.
+ if ( !markerRange.isCollapsed ) {
+ viewWriter.clear( conversionApi.mapper.toViewRange( markerRange ).getEnlarged(), viewEndElement );
+ }
+
+ // Remove "opening" element.
+ viewWriter.clear( conversionApi.mapper.toViewRange( markerRange ).getEnlarged(), viewStartElement );
+
+ evt.stop();
};
}
/**
- * Function factory, creates a converter that converts remove attribute changes from the model to the view. Removes attributes
- * that were converted to the view element attributes in the view. You may provide a custom function to generate a
- * key-value attribute pair to remove. If not provided, model attributes will be removed from view elements on 1-to-1 basis.
+ * Function factory, creates a converter that converts set/change/remove attribute changes from the model to the view.
*
- * **Note:** Provided attribute creator should always return the same `key` for given attribute from the model.
+ * Attributes from model are converted to the view element attributes in the view. You may provide a custom function to generate
+ * a key-value attribute pair to add/change/remove. If not provided, model attributes will be converted to view elements
+ * attributes on 1-to-1 basis.
*
- * **Note:** You can use the same attribute creator as in {@link module:engine/conversion/model-to-view-converters~setAttribute}.
+ * **Note:** Provided attribute creator should always return the same `key` for given attribute from the model.
*
* The converter automatically consumes corresponding value from consumables list and stops the event (see
* {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}).
*
- * modelDispatcher.on( 'removeAttribute:customAttr:myElem', removeAttribute( ( data ) => {
+ * modelDispatcher.on( 'attribute:customAttr:myElem', changeAttribute( ( data ) => {
* // Change attribute key from `customAttr` to `class` in view.
* const key = 'class';
* let value = data.attributeNewValue;
@@ -223,31 +254,35 @@ export function setAttribute( attributeCreator ) {
* } ) );
*
* @param {Function} [attributeCreator] Function returning an object with two properties: `key` and `value`, which
- * represents attribute key and attribute value to be removed from {@link module:engine/view/element~Element view element}.
+ * represents attribute key and attribute value to be set on a {@link module:engine/view/element~Element view element}.
* The function is passed all the parameters of the
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addAttribute addAttribute event}
- * or {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:changeAttribute changeAttribute event}.
- * @returns {Function} Remove attribute converter.
+ * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:attribute} event.
+ * @returns {Function} Set/change attribute converter.
*/
-export function removeAttribute( attributeCreator ) {
- attributeCreator = attributeCreator || ( ( value, key ) => ( { key } ) );
+export function changeAttribute( attributeCreator ) {
+ attributeCreator = attributeCreator || ( ( value, key ) => ( { value, key } ) );
return ( evt, data, consumable, conversionApi ) => {
if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
return;
}
- const { key } = attributeCreator( data.attributeOldValue, data.attributeKey, data, consumable, conversionApi );
+ const { key, value } = attributeCreator( data.attributeNewValue, data.attributeKey, data, consumable, conversionApi );
- conversionApi.mapper.toViewElement( data.item ).removeAttribute( key );
+ if ( data.attributeNewValue !== null ) {
+ conversionApi.mapper.toViewElement( data.item ).setAttribute( key, value );
+ } else {
+ conversionApi.mapper.toViewElement( data.item ).removeAttribute( key );
+ }
};
}
/**
- * Function factory, creates a converter that converts set/change attribute changes from the model to the view. In this case,
- * model attributes are converted to a view element that will be wrapping view nodes which corresponding model nodes had
- * the attribute set. This is useful for attributes like `bold`, which may be set on text nodes in model but are
- * represented as an element in the view:
+ * Function factory, creates a converter that converts set/change/remove attribute changes from the model to the view.
+ *
+ * Attributes from model are converted to a view element that will be wrapping those view nodes that are bound to
+ * model elements having given attribute. This is useful for attributes like `bold`, which may be set on text nodes in model
+ * but are represented as an element in the view:
*
* [paragraph] MODEL ====> VIEW
* |- a {bold: true} |-
@@ -256,7 +291,7 @@ export function removeAttribute( attributeCreator ) {
*
* The wrapping node depends on passed parameter. If {@link module:engine/view/element~Element} was passed, it will be cloned and
* the copy will become the wrapping element. If `Function` is provided, it is passed attribute value and then all the parameters of the
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addAttribute addAttribute event}.
+ * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:attribute attribute event}.
* It's expected that the function returns a {@link module:engine/view/element~Element}.
* The result of the function will be the wrapping element.
* When provided `Function` does not return element, then will be no conversion.
@@ -264,19 +299,26 @@ export function removeAttribute( attributeCreator ) {
* The converter automatically consumes corresponding value from consumables list, stops the event (see
* {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}).
*
- * modelDispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
+ * modelDispatcher.on( 'attribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
*
* @param {module:engine/view/element~Element|Function} elementCreator View element, or function returning a view element, which will
* be used for wrapping.
* @returns {Function} Set/change attribute converter.
*/
-export function wrapItem( elementCreator ) {
+export function wrap( elementCreator ) {
return ( evt, data, consumable, conversionApi ) => {
- const viewElement = ( elementCreator instanceof ViewElement ) ?
+ // 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 instanceof ViewElement ) ?
+ elementCreator.clone( true ) :
+ elementCreator( data.attributeOldValue, data, consumable, conversionApi );
+
+ // Create node to wrap with.
+ const newViewElement = ( elementCreator instanceof ViewElement ) ?
elementCreator.clone( true ) :
elementCreator( data.attributeNewValue, data, consumable, conversionApi );
- if ( !viewElement ) {
+ if ( !oldViewElement && !newViewElement ) {
return;
}
@@ -286,208 +328,101 @@ export function wrapItem( elementCreator ) {
let viewRange = conversionApi.mapper.toViewRange( data.range );
- // If this is a change event (because old value is not empty) and the creator is a function (so
- // it may create different view elements basing on attribute value) we have to create
- // view element basing on old value and unwrap it before wrapping with a newly created view element.
- if ( data.attributeOldValue !== null && !( elementCreator instanceof ViewElement ) ) {
- const oldViewElement = elementCreator( data.attributeOldValue, data, consumable, conversionApi );
+ // First, unwrap the range from current wrapper.
+ if ( data.attributeOldValue !== null ) {
viewRange = viewWriter.unwrap( viewRange, oldViewElement );
}
- viewWriter.wrap( viewRange, viewElement );
- };
-}
-
-/**
- * Function factory, creates a converter that converts remove attribute changes from the model to the view. It assumes, that
- * attributes from model were converted to elements in the view. This converter will unwrap view nodes from corresponding
- * view element if given attribute was removed.
- *
- * The view element type that will be unwrapped depends on passed parameter.
- * If {@link module:engine/view/element~Element} was passed, it will be used to look for similar element in the view for unwrapping.
- * If `Function` is provided, it is passed all the parameters of the
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addAttribute addAttribute event}.
- * It's expected that the function returns a {@link module:engine/view/element~Element}.
- * The result of the function will be used to look for similar element in the view for unwrapping.
- *
- * The converter automatically consumes corresponding value from consumables list, stops the event (see
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}) and bind model and view elements.
- *
- * modelDispatcher.on( 'removeAttribute:bold', unwrapItem( new ViewAttributeElement( 'strong' ) ) );
- *
- * @see module:engine/conversion/model-to-view-converters~wrapItem
- * @param {module:engine/view/element~Element|Function} elementCreator View element, or function returning a view element, which will
- * be used for unwrapping.
- * @returns {Function} Remove attribute converter.
- */
-export function unwrapItem( elementCreator ) {
- return ( evt, data, consumable, conversionApi ) => {
- const viewElement = ( elementCreator instanceof ViewElement ) ?
- elementCreator.clone( true ) :
- elementCreator( data.attributeOldValue, data, consumable, conversionApi );
-
- if ( !viewElement ) {
- return;
+ // Then wrap with the new wrapper.
+ if ( data.attributeNewValue !== null ) {
+ viewWriter.wrap( viewRange, newViewElement );
}
-
- if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
- return;
- }
-
- const viewRange = conversionApi.mapper.toViewRange( data.range );
-
- viewWriter.unwrap( viewRange, viewElement );
};
}
/**
- * Function factory, creates a default model-to-view converter for node remove changes.
+ * Function factory, creates converter that converts text inside marker's range. Converter wraps the text with
+ * {@link module:engine/view/attributeelement~AttributeElement} created from provided descriptor.
+ * See {link module:engine/conversion/model-to-view-converters~createViewElementFromHighlightDescriptor}.
*
- * modelDispatcher.on( 'remove', remove() );
+ * If the highlight descriptor will not provide `priority` property, `10` will be used.
*
- * @returns {Function} Remove event converter.
- */
-export function remove() {
- return ( evt, data, consumable, conversionApi ) => {
- if ( !consumable.consume( data.item, 'remove' ) ) {
- return;
- }
-
- // We cannot map non-existing positions from model to view. Since a range was removed
- // from the model, we cannot recreate that range and map it to view, because
- // end of that range is incorrect.
- // Instead we will use `data.sourcePosition` as this is the last correct model position and
- // it is a position before the removed item. Then, we will calculate view range to remove "manually".
- let viewPosition = conversionApi.mapper.toViewPosition( data.sourcePosition );
- let viewRange;
-
- if ( data.item.is( 'element' ) ) {
- // Note: in remove conversion we cannot use model-to-view element mapping because `data.item` may be
- // already mapped to another element (this happens when move change is converted).
- // In this case however, `viewPosition` is the position before view element that corresponds to removed model element.
- //
- // First, fix the position. Traverse the tree forward until the container element is found. The `viewPosition`
- // may be before a ui element, before attribute element or at the end of text element.
- viewPosition = viewPosition.getLastMatchingPosition( value => !value.item.is( 'containerElement' ) );
-
- if ( viewPosition.parent.is( 'text' ) && viewPosition.isAtEnd ) {
- viewPosition = ViewPosition.createAfter( viewPosition.parent );
- }
-
- viewRange = ViewRange.createOn( viewPosition.nodeAfter );
- } else {
- // If removed item is a text node, we need to traverse view tree to find the view range to remove.
- // Range to remove will start `viewPosition` and should contain amount of characters equal to the amount of removed characters.
- const viewRangeEnd = _shiftViewPositionByCharacters( viewPosition, data.item.offsetSize );
- viewRange = new ViewRange( viewPosition, viewRangeEnd );
- }
-
- // Trim the range to remove in case some UI elements are on the view range boundaries.
- viewWriter.remove( viewRange.getTrimmed() );
-
- // Unbind this element only if it was moved to graveyard.
- // The dispatcher#remove event will also be fired if the element was moved to another place (remove+insert are fired).
- // Let's say that is moved before . The view will be changed like this:
- //
- // 1) start:
- // 2) insert:
- // 3) remove:
- //
- // If we'll unbind the element in step 3 we'll also lose binding of the element in the view,
- // because unbindModelElement() cancels both bindings – (model => view ) and (view => model ).
- // We can't lose any of these.
- //
- // See #847.
- if ( data.item.root.rootName == '$graveyard' ) {
- conversionApi.mapper.unbindModelElement( data.item );
- }
- };
-}
-
-/**
- * Function factory, creates converter that converts all texts inside marker's range. Converter wraps each text with
- * {@link module:engine/view/attributeelement~AttributeElement} created from provided descriptor.
- * See {link module:engine/conversion/model-to-view-converters~highlightDescriptorToAttributeElement}.
+ * If the highlight descriptor will not provide `id` property, name of the marker will be used.
*
* @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor|Function} highlightDescriptor
* @return {Function}
*/
export function highlightText( highlightDescriptor ) {
return ( evt, data, consumable, conversionApi ) => {
- const descriptor = typeof highlightDescriptor == 'function' ?
- highlightDescriptor( data, consumable, conversionApi ) :
- highlightDescriptor;
+ if ( data.markerRange.isCollapsed ) {
+ return;
+ }
const modelItem = data.item;
- if ( !descriptor || data.markerRange.isCollapsed || !modelItem.is( 'textProxy' ) ) {
+ if ( !modelItem.is( 'textProxy' ) ) {
return;
}
- if ( !consumable.consume( modelItem, evt.name ) ) {
+ const descriptor = _prepareDescriptor( highlightDescriptor, data, conversionApi );
+
+ if ( !descriptor ) {
return;
}
- if ( !descriptor.id ) {
- descriptor.id = data.markerName;
+ if ( !consumable.consume( modelItem, evt.name ) ) {
+ return;
}
const viewElement = createViewElementFromHighlightDescriptor( descriptor );
const viewRange = conversionApi.mapper.toViewRange( data.range );
- if ( evt.name.split( ':' )[ 0 ] == 'addMarker' ) {
- viewWriter.wrap( viewRange, viewElement );
- } else {
- viewWriter.unwrap( viewRange, viewElement );
- }
+ viewWriter.wrap( viewRange, viewElement );
};
}
/**
- * Converter function factory. Creates a function which applies the marker's highlight to all elements inside a marker's range.
- * The converter checks if an element has the addHighlight and removeHighlight functions stored as
- * {@link module:engine/view/element~Element#setCustomProperty custom properties} and if so use them to apply the highlight.
+ * Converter function factory. Creates a function which applies the marker's highlight to an element inside the marker's range.
+ *
+ * The converter checks if an element has `addHighlight` function stored as
+ * {@link module:engine/view/element~Element#setCustomProperty custom property} and, if so, uses it to apply the highlight.
* In such case converter will consume all element's children, assuming that they were handled by element itself.
- * If the highlight descriptor will not provide priority, priority `10` will be used as default, to be compliant with
- * {@link module:engine/conversion/model-to-view-converters~highlightText} method which uses default priority of
- * {@link module:engine/view/attributeelement~AttributeElement}.
+ *
+ * When `addHighlight` custom property is not present, element is not converted in any special way.
+ * This means that converters will proceed to convert element's child nodes.
+ *
+ * If the highlight descriptor will not provide `priority` property, `10` will be used.
*
* If the highlight descriptor will not provide `id` property, name of the marker will be used.
- * When `addHighlight` and `removeHighlight` custom properties are not present, element is not converted
- * in any special way. This means that converters will proceed to convert element's child nodes.
*
* @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor|Function} highlightDescriptor
* @return {Function}
*/
export function highlightElement( highlightDescriptor ) {
return ( evt, data, consumable, conversionApi ) => {
- const descriptor = typeof highlightDescriptor == 'function' ?
- highlightDescriptor( data, consumable, conversionApi ) :
- highlightDescriptor;
+ if ( data.markerRange.isCollapsed ) {
+ return;
+ }
const modelItem = data.item;
- if ( !descriptor || data.markerRange.isCollapsed || !modelItem.is( 'element' ) ) {
+ if ( !modelItem.is( 'element' ) ) {
return;
}
- if ( !consumable.test( data.item, evt.name ) ) {
- return;
- }
+ const descriptor = _prepareDescriptor( highlightDescriptor, data, conversionApi );
- if ( !descriptor.priority ) {
- descriptor.priority = 10;
+ if ( !descriptor ) {
+ return;
}
- if ( !descriptor.id ) {
- descriptor.id = data.markerName;
+ if ( !consumable.test( modelItem, evt.name ) ) {
+ return;
}
const viewElement = conversionApi.mapper.toViewElement( modelItem );
- const addMarker = evt.name.split( ':' )[ 0 ] == 'addMarker';
- const highlightHandlingMethod = addMarker ? 'addHighlight' : 'removeHighlight';
- if ( viewElement && viewElement.getCustomProperty( highlightHandlingMethod ) ) {
+ if ( viewElement && viewElement.getCustomProperty( 'addHighlight' ) ) {
// Consume element itself.
consumable.consume( data.item, evt.name );
@@ -496,65 +431,99 @@ export function highlightElement( highlightDescriptor ) {
consumable.consume( value.item, evt.name );
}
- viewElement.getCustomProperty( highlightHandlingMethod )( viewElement, addMarker ? descriptor : descriptor.id );
+ viewElement.getCustomProperty( 'addHighlight' )( viewElement, descriptor );
}
};
}
/**
- * Function factory, creates a default model-to-view converter for removing {@link module:engine/view/uielement~UIElement ui element}
- * basing on marker remove change.
+ * Function factory, creates a converter that converts model marker remove to the view.
*
- * @param {module:engine/view/uielement~UIElement|Function} elementCreator View ui element, or function returning
- * a view ui element, which will be used as a pattern when look for element to remove at the marker start position.
- * @returns {Function} Remove ui element converter.
+ * Both text nodes and elements are handled by this converter by they are handled a bit differently.
+ *
+ * Text nodes are unwrapped using {@link module:engine/view/attributeelement~AttributeElement} created from provided
+ * highlight descriptor. See {link module:engine/conversion/model-to-view-converters~highlightDescriptorToAttributeElement}.
+ *
+ * For elements, the converter checks if an element has `removeHighlight` function stored as
+ * {@link module:engine/view/element~Element#setCustomProperty custom property}. If so, it uses it to remove the highlight.
+ * In such case, children of that element will not be converted.
+ *
+ * When `removeHighlight` is not present, element is not converted in any special way.
+ * Instead converter will proceed to convert element's child nodes.
+ *
+ * If the highlight descriptor will not provide `priority` property, `10` will be used.
+ *
+ * If the highlight descriptor will not provide `id` property, name of the marker will be used.
+ *
+ * @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor|Function} highlightDescriptor
+ * @return {Function}
*/
-export function removeUIElement( elementCreator ) {
- return ( evt, data, consumable, conversionApi ) => {
- let viewStartElement, viewEndElement;
-
- if ( elementCreator instanceof ViewElement ) {
- viewStartElement = elementCreator.clone( true );
- viewEndElement = elementCreator.clone( true );
- } else {
- data.isOpening = true;
- viewStartElement = elementCreator( data, consumable, conversionApi );
-
- data.isOpening = false;
- viewEndElement = elementCreator( data, consumable, conversionApi );
- }
-
- if ( !viewStartElement || !viewEndElement ) {
+export function removeHighlight( highlightDescriptor ) {
+ return ( evt, data, conversionApi ) => {
+ // This conversion makes sense only for non-collapsed range.
+ if ( data.markerRange.isCollapsed ) {
return;
}
- const markerRange = data.markerRange;
- const eventName = evt.name;
+ const descriptor = _prepareDescriptor( highlightDescriptor, data, conversionApi );
- // If marker's range is collapsed - check if it can be consumed.
- if ( markerRange.isCollapsed && !consumable.consume( markerRange, eventName ) ) {
+ if ( !descriptor ) {
return;
}
- // Check if all items in the range can be consumed, and consume them.
- for ( const value of markerRange ) {
- if ( !consumable.consume( value.item, eventName ) ) {
- return;
+ const viewRange = conversionApi.mapper.toViewRange( data.markerRange );
+
+ // Retrieve all items in the affected range. We will process them and remove highlight from them appropriately.
+ const items = new Set( viewRange.getItems() );
+
+ // First, iterate through all items and remove highlight from those container elements that have custom highlight handling.
+ for ( const item of items ) {
+ if ( item.is( 'containerElement' ) && item.getCustomProperty( 'removeHighlight' ) ) {
+ item.getCustomProperty( 'removeHighlight' )( item, descriptor.id );
+
+ // If container element had custom handling, remove all it's children from further processing.
+ for ( const descendant of ViewRange.createIn( item ) ) {
+ items.delete( descendant );
+ }
}
}
- const viewRange = conversionApi.mapper.toViewRange( markerRange );
-
- // First remove closing element.
- viewWriter.clear( viewRange.getEnlarged(), viewEndElement );
+ // Then, iterate through all other items. Look for text nodes and unwrap them. Start from the end
+ // to prevent errors when view structure changes when unwrapping (and, for example, some attributes are merged).
+ const viewHighlightElement = createViewElementFromHighlightDescriptor( descriptor );
- // If closing and opening elements are not the same then remove opening element.
- if ( !viewStartElement.isSimilar( viewEndElement ) ) {
- viewWriter.clear( viewRange.getEnlarged(), viewStartElement );
+ for ( const item of Array.from( items ).reverse() ) {
+ if ( item.is( 'textProxy' ) ) {
+ viewWriter.unwrap( ViewRange.createOn( item ), viewHighlightElement );
+ }
}
};
}
+// Helper function for `highlight`. Prepares the actual descriptor object using value passed to the converter.
+function _prepareDescriptor( highlightDescriptor, data, conversionApi ) {
+ // If passed descriptor is a creator function, call it. If not, just use passed value.
+ const descriptor = typeof highlightDescriptor == 'function' ?
+ highlightDescriptor( data, conversionApi ) :
+ highlightDescriptor;
+
+ if ( !descriptor ) {
+ return null;
+ }
+
+ // Apply default descriptor priority.
+ if ( !descriptor.priority ) {
+ descriptor.priority = 10;
+ }
+
+ // Default descriptor id is marker name.
+ if ( !descriptor.id ) {
+ descriptor.id = data.markerName;
+ }
+
+ return descriptor;
+}
+
/**
* Returns the consumable type that is to be consumed in an event, basing on that event name.
*
@@ -567,26 +536,6 @@ export function eventNameToConsumableType( evtName ) {
return parts[ 0 ] + ':' + parts[ 1 ];
}
-// Helper function that shifts given view `position` in a way that returned position is after `howMany` characters compared
-// to the original `position`.
-// Because in view there might be view ui elements splitting text nodes, we cannot simply use `ViewPosition#getShiftedBy()`.
-function _shiftViewPositionByCharacters( position, howMany ) {
- // Create a walker that will walk the view tree starting from given position and walking characters one-by-one.
- const walker = new ViewTreeWalker( { startPosition: position, singleCharacters: true } );
- // We will count visited characters and return the position after `howMany` characters.
- let charactersFound = 0;
-
- for ( const value of walker ) {
- if ( value.type == 'text' ) {
- charactersFound++;
-
- if ( charactersFound == howMany ) {
- return walker.position;
- }
- }
- }
-}
-
/**
* Creates `span` {@link module:engine/view/attributeelement~AttributeElement view attribute element} from information
* provided by {@link module:engine/conversion/model-to-view-converters~HighlightDescriptor} object. If priority
diff --git a/src/conversion/modelconversiondispatcher.js b/src/conversion/modelconversiondispatcher.js
index b5762a371..619c3a442 100644
--- a/src/conversion/modelconversiondispatcher.js
+++ b/src/conversion/modelconversiondispatcher.js
@@ -9,57 +9,72 @@
import Consumable from './modelconsumable';
import Range from '../model/range';
-import Position from '../model/position';
-import DocumentFragment from '../model/documentfragment';
import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
import mix from '@ckeditor/ckeditor5-utils/src/mix';
import extend from '@ckeditor/ckeditor5-utils/src/lib/lodash/extend';
/**
- * `ModelConversionDispatcher` is a central point of {@link module:engine/model/model model} conversion, which is
- * a process of reacting to changes in the model and reflecting them by listeners that listen to those changes.
- * In default application, {@link module:engine/model/model model} is converted to {@link module:engine/view/view view}. This means
- * that changes in the model are reflected by changing the view (i.e. adding view nodes or changing attributes on view elements).
+ * `ModelConversionDispatcher` is a central point of model conversion, which is a process of reacting to changes
+ * in the model and firing a set of events. Callbacks listening to those events are called converters. Those
+ * converters role is to convert the model changes to changes in view (for example, adding view nodes or
+ * changing attributes on view elements).
*
- * During conversion process, `ModelConversionDispatcher` fires data-manipulation events, basing on state of the model and prepares
- * data for those events. It is important to note that the events are connected with "change actions" like "inserting"
- * or "removing" so one might say that we are converting "changes". This is in contrary to view to model conversion,
- * where we convert view nodes (the structure, not "changes" to the view). Note, that because changes are converted
- * and not the structure itself, there is a need to have a mapping between model and the structure on which changes are
- * reflected. To map elements during model to view conversion use {@link module:engine/conversion/mapper~Mapper}.
+ * During conversion process, `ModelConversionDispatcher` fires events, basing on state of the model and prepares
+ * data for those events. It is important to understand that those events are connected with changes done on model,
+ * for example: "node has been inserted" or "attribute has changed". This is in contrary to view to model conversion,
+ * where we convert view state (view nodes) to a model tree.
*
- * The main use for this class is to listen to {@link module:engine/model/document~Document#event:change Document change event}, process it
- * and then fire specific events telling what exactly has changed. For those events, `ModelConversionDispatcher`
- * creates {@link module:engine/conversion/modelconsumable~ModelConsumable list of consumable values} that should be handled by event
- * callbacks. Those events are listened to by model-to-view converters which convert changes done in the
- * {@link module:engine/model/model model} to changes in the {@link module:engine/view/view view}. `ModelConversionController` also checks
- * the current state of consumables, so it won't fire events for parts of model that were already consumed. This is
- * especially important in callbacks that consume multiple values. See {@link module:engine/conversion/modelconsumable~ModelConsumable}
- * for an example of such callback.
+ * The events are prepared basing on a diff created by {@link module:engine/model/differ~Differ Differ}, which buffers them
+ * and then passes to `ModelConversionDispatcher` as a diff between old model state and new model state.
*
- * Although the primary usage for this class is the model-to-view conversion, `ModelConversionDispatcher` can be used
- * to build custom data processing pipelines that converts model to anything that is needed. Existing model structure can
- * be used to generate events (listening to {@link module:engine/model/document~Document#event:change Document change event} is not
- * required)
- * and custom callbacks can be added to the events (these does not have to be limited to changes in the view).
+ * Note, that because changes are converted there is a need to have a mapping between model structure and view structure.
+ * To map positions and elements during model to view conversion use {@link module:engine/conversion/mapper~Mapper}.
*
- * When providing your own event listeners for `ModelConversionDispatcher` keep in mind that any callback that had
- * {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed} a value from consumable (and did some changes, i.e. to
- * the view) should also stop the event. This is because whenever a callback is fired it is assumed that there is something
- * to be consumed. Thanks to that approach, you do not have to test whether there is anything to consume at the beginning
- * of your listener callback.
+ * `ModelConversionDispatcher` fires following events for model tree changes:
+ * * {@link #event:insert insert} if a range of nodes has been inserted to the model tree,
+ * * {@link #event:remove remove} if a range of nodes has been removed from the model tree,
+ * * {@link #event:attribute attribute} if attribute has been added, changed or removed from a model node.
*
- * Example of providing a converter for `ModelConversionDispatcher`:
+ * For {@link #event:insert insert} and {@link #event:attribute attribute}, `ModelConversionDispatcher` generates
+ * {@link module:engine/conversion/modelconsumable~ModelConsumable consumables}. These are used to have a control
+ * over which changes has been already consumed. It is useful when some converters overwrite other or converts multiple
+ * changes (for example converts insertion of an element and also converts that element's attributes during insertion).
+ *
+ * Additionally, `ModelConversionDispatcher` fires events for {@link module:engine/model/markerscollection~Marker marker} changes:
+ * * {@link #event:addMarker} if a marker has been added,
+ * * {@link #event:removeMarker} if a marker has been removed.
+ *
+ * Note, that changing a marker is done through removing the marker from the old range, and adding on the new range,
+ * so both those events are fired.
+ *
+ * Finally, `ModelConversionDispatcher` also handles firing events for {@link module:engine/model/selection model selection}
+ * conversion:
+ * * {@link #event:selection} which converts selection from model to view,
+ * * {@link #event:selectionAttribute} which is fired for every selection attribute,
+ * * {@link #event:selectionMarker} which is fired for every marker which contains selection.
+ *
+ * Unlike model tree and markers, events for selection are not fired for changes but for selection state.
+ *
+ * When providing custom listeners for `ModelConversionDispatcher` remember to check whether given change has not been
+ * {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed} yet.
+ *
+ * When providing custom listeners for `ModelConversionDispatcher` keep in mind that any callback that had
+ * {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed} a value from a consumable and
+ * converted the change should also stop the event (for efficiency purposes).
+ *
+ * Example of a custom converter for `ModelConversionDispatcher`:
*
* // We will convert inserting "paragraph" model element into the model.
* modelDispatcher.on( 'insert:paragraph', ( evt, data, consumable, conversionApi ) => {
- * // Remember to consume the part of consumable.
- * consumable.consume( data.item, 'insert' );
+ * // Remember to check whether the change has not been consumed yet and consume it.
+ * if ( consumable.consume( data.item, 'insert' ) ) {
+ * return;
+ * }
*
- * // Translate position in model to position in the view.
+ * // Translate position in model to position in view.
* const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
*
- * // Create a P element (note that this converter is for inserting P elements -> 'insert:paragraph').
+ * // Create
element that will be inserted in view at `viewPosition`.
* const viewElement = new ViewElement( 'p' );
*
* // Bind the newly created view element to model element so positions will map accordingly in future.
@@ -68,50 +83,16 @@ import extend from '@ckeditor/ckeditor5-utils/src/lib/lodash/extend';
* // Add the newly created view element to the view.
* viewWriter.insert( viewPosition, viewElement );
*
- * // Remember to stop the event propagation if the data.item was consumed.
+ * // Remember to stop the event propagation.
* evt.stop();
* } );
- *
- * Callback that "overrides" other callback:
- *
- * // Special converter for `linkHref` attribute added on custom `quote` element. Note, that this
- * // attribute may be the same as the attribute added by other features (link feature in this case).
- * // It might be even added by that feature! It makes sense that a part of content that is a quote is linked
- * // to an external source so it makes sense that link feature works on the custom quote element.
- * // However, we have to make sure that the attributes added by link feature are correctly converted.
- * // To block default `linkHref` conversion we have to:
- * // 1) add this callback with higher priority than link feature callback,
- * // 2) consume `linkHref` attribute add change.
- * modelConversionDispatcher.on( 'addAttribute:linkHref:quote', ( evt, data, consumable, conversionApi ) => {
- * consumable.consume( data.item, 'addAttribute:linkHref' );
- *
- * // Create a button that will represent the `linkHref` attribute.
- * let viewSourceBtn = new ViewElement( 'a', {
- * href: data.item.getAttribute( 'linkHref' ),
- * title: 'source'
- * } );
- *
- * // Add a class for the button.
- * viewSourceBtn.addClass( 'source' );
- *
- * // Insert the button using writer API.
- * // If `addAttribute` event is fired by
- * // `module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#convertInsert` it is fired
- * // after `data.item` insert conversion was done. If the event is fired due to attribute insertion coming from
- * // different source, `data.item` already existed. This means we are safe to get `viewQuote` from mapper.
- * const viewQuote = conversionApi.mapper.toViewElement( data.item );
- * const position = new ViewPosition( viewQuote, viewQuote.childCount );
- * viewWriter.insert( position, viewSourceBtn );
- *
- * evt.stop();
- * }, { priority: 'high' } );
*/
export default class ModelConversionDispatcher {
/**
- * Creates a `ModelConversionDispatcher` that operates using passed API.
+ * Creates a `ModelConversionDispatcher` instance.
*
* @param {module:engine/model/model~Model} model Data model.
- * @param {Object} [conversionApi] Interface passed by dispatcher to the events callbacks.
+ * @param {Object} [conversionApi] Interface passed by dispatcher to the events calls.
*/
constructor( model, conversionApi = {} ) {
/**
@@ -131,64 +112,50 @@ export default class ModelConversionDispatcher {
}
/**
- * Prepares data and fires a proper event.
+ * Takes {@link module:engine/model/differ~Differ model differ} object with buffered changes and fires conversion basing on it.
*
- * The method is crafted to take use of parameters passed in {@link module:engine/model/document~Document#event:change Document change
- * event}.
- *
- * @see module:engine/model/document~Document#event:change
- * @fires insert
- * @fires remove
- * @fires addAttribute
- * @fires removeAttribute
- * @fires changeAttribute
- * @fires addMarker
- * @param {String} type Change type.
- * @param {Object} data Additional information about the change.
+ * @param {module:engine/model/differ~Differ} differ Differ object with buffered changes.
*/
- convertChange( type, data ) {
- // Do not convert changes if they happen in graveyard.
- // Graveyard is a special root that has no view / no other representation and changes done in it should not be converted.
- if ( type !== 'remove' && data.range && data.range.root.rootName == '$graveyard' ) {
- return;
+ convertChanges( differ ) {
+ // First, before changing view structure, remove all markers that has changed.
+ for ( const change of differ.getMarkersToRemove() ) {
+ this.convertMarkerRemove( change.name, change.range );
}
- if ( type == 'remove' && data.sourcePosition.root.rootName == '$graveyard' ) {
- return;
- }
+ // Convert changes that happened on model tree.
+ for ( const entry of differ.getChanges() ) {
+ // Skip all the changes that happens in graveyard. These are not converted.
+ if ( _isInGraveyard( entry ) ) {
+ continue;
+ }
- if ( type == 'rename' && data.element.root.rootName == '$graveyard' ) {
- return;
+ if ( entry.type == 'insert' ) {
+ this.convertInsert( Range.createFromPositionAndShift( entry.position, entry.length ) );
+ } else if ( entry.type == 'remove' ) {
+ this.convertRemove( entry.position, entry.length, entry.name );
+ } else {
+ // entry.type == 'attribute'.
+ this.convertAttribute( entry.range, entry.attributeKey, entry.attributeOldValue, entry.attributeNewValue );
+ }
}
- // We can safely dispatch changes.
- if ( type == 'insert' || type == 'reinsert' ) {
- this.convertInsertion( data.range );
- } else if ( type == 'move' ) {
- this.convertMove( data.sourcePosition, data.range );
- } else if ( type == 'remove' ) {
- this.convertRemove( data.sourcePosition, data.range );
- } else if ( type == 'addAttribute' || type == 'removeAttribute' || type == 'changeAttribute' ) {
- this.convertAttribute( type, data.range, data.key, data.oldValue, data.newValue );
- } else if ( type == 'rename' ) {
- this.convertRename( data.element, data.oldName );
+ // After the view is updated, convert markers which has changed.
+ for ( const change of differ.getMarkersToAdd() ) {
+ this.convertMarkerAdd( change.name, change.range );
}
}
/**
- * Starts conversion of insertion-change on given `range`.
+ * Starts conversion of a range insertion.
*
- * Analyzes given range and fires insertion-connected events with data based on that range.
- *
- * **Note**: This method will fire separate events for node insertion and attributes insertion. All
- * attributes that are set on inserted nodes are treated like they were added just after node insertion.
+ * For each node in the range, {@link #event:insert insert event is fired}. For each attribute on each node,
+ * {@link #event:attribute attribute event is fired}.
*
* @fires insert
- * @fires addAttribute
- * @fires addMarker
+ * @fires attribute
* @param {module:engine/model/range~Range} range Inserted range.
*/
- convertInsertion( range ) {
+ convertInsert( range ) {
// Create a list of things that can be consumed, consisting of nodes and their attributes.
const consumable = this._createInsertConsumable( range );
@@ -211,91 +178,36 @@ export default class ModelConversionDispatcher {
data.attributeOldValue = null;
data.attributeNewValue = item.getAttribute( key );
- this._testAndFire( `addAttribute:${ key }`, data, consumable );
- }
- }
-
- for ( const marker of this._model.markers ) {
- const markerRange = marker.getRange();
- const intersection = markerRange.getIntersection( range );
-
- // Check if inserted content is inserted into a marker.
- if ( intersection && shouldMarkerChangeBeConverted( range.start, marker, this.conversionApi.mapper ) ) {
- this.convertMarker( 'addMarker', marker.name, intersection );
+ this._testAndFire( `attribute:${ key }`, data, consumable );
}
}
}
/**
- * Starts conversion of move-change of given `range`, that was moved from given `sourcePosition`.
- *
- * Fires {@link ~#event:remove remove event} and {@link ~#event:insert insert event} based on passed parameters.
- *
- * @fires remove
- * @fires insert
- * @param {module:engine/model/position~Position} sourcePosition The original position from which the range was moved.
- * @param {module:engine/model/range~Range} range The range containing the moved content.
- */
- convertMove( sourcePosition, range ) {
- // Move left – convert insertion first (#847).
- if ( range.start.isBefore( sourcePosition ) ) {
- this.convertInsertion( range );
-
- const sourcePositionAfterInsertion =
- sourcePosition._getTransformedByInsertion( range.start, range.end.offset - range.start.offset );
-
- this.convertRemove( sourcePositionAfterInsertion, range );
- } else {
- this.convertRemove( sourcePosition, range );
- this.convertInsertion( range );
- }
- }
-
- /**
- * Starts conversion of remove-change of given `range`, that was removed from given `sourcePosition`.
- *
- * Fires {@link ~#event:remove remove event} with data based on passed values.
+ * Fires conversion of a single node removal. Fires {@link #event:remove remove event} with provided data.
*
- * @fires remove
- * @param {module:engine/model/position~Position} sourcePosition Position from where the range has been removed.
- * @param {module:engine/model/range~Range} range Removed range (after remove, in
- * {@link module:engine/model/document~Document#graveyard graveyard root}).
+ * @param {module:engine/model/position~Position} position Position from which node was removed.
+ * @param {Number} length Offset size of removed node.
+ * @param {String} name Name of removed node.
*/
- convertRemove( sourcePosition, range ) {
- const consumable = this._createConsumableForRange( range, 'remove' );
-
- for ( const item of range.getItems( { shallow: true } ) ) {
- const data = {
- sourcePosition,
- item
- };
-
- this._testAndFire( 'remove', data, consumable );
- }
+ convertRemove( position, length, name ) {
+ this.fire( 'remove:' + name, { position, length }, this.conversionApi );
}
/**
- * Starts conversion of attribute-change on given `range`.
+ * Starts conversion of attribute change on given `range`.
*
- * Analyzes given attribute change and fires attributes-connected events with data based on passed values.
+ * For each node in the given `range`, {@link #event:attribute attribute event} is fired with the passed data.
*
- * @fires addAttribute
- * @fires removeAttribute
- * @fires changeAttribute
- * @param {String} type Change type. Possible values: `addAttribute`, `removeAttribute`, `changeAttribute`.
+ * @fires attribute
* @param {module:engine/model/range~Range} range Changed range.
- * @param {String} key Attribute key.
- * @param {*} oldValue Attribute value before the change or `null` if attribute has not been set.
- * @param {*} newValue New attribute value or `null` if attribute has been removed.
+ * @param {String} key Key of the attribute that has changed.
+ * @param {*} oldValue Attribute value before the change or `null` if the attribute has not been set before.
+ * @param {*} newValue New attribute value or `null` if the attribute has been removed.
*/
- convertAttribute( type, range, key, oldValue, newValue ) {
- if ( oldValue == newValue ) {
- // Do not convert if the attribute did not change.
- return;
- }
-
+ convertAttribute( range, key, oldValue, newValue ) {
// Create a list with attributes to consume.
- const consumable = this._createConsumableForRange( range, type + ':' + key );
+ const consumable = this._createConsumableForRange( range, `attribute:${ key }` );
// Create a separate attribute event for each node in the range.
for ( const value of range ) {
@@ -309,50 +221,17 @@ export default class ModelConversionDispatcher {
attributeNewValue: newValue
};
- this._testAndFire( `${ type }:${ key }`, data, consumable );
+ this._testAndFire( `attribute:${ key }`, data, consumable );
}
}
/**
- * Starts conversion of rename-change of given `element` that had given `oldName`.
- *
- * Fires {@link ~#event:remove remove event} and {@link ~#event:insert insert event} based on passed values.
- *
- * @fires remove
- * @fires insert
- * @param {module:engine/model/element~Element} element Renamed element.
- * @param {String} oldName Name of the renamed element before it was renamed.
- */
- convertRename( element, oldName ) {
- if ( element.name == oldName ) {
- // Do not convert if the name did not change.
- return;
- }
-
- // Create fake element that will be used to fire remove event. The fake element will have the old element name.
- const fakeElement = element.clone( true );
- fakeElement.name = oldName;
-
- // Bind fake element with original view element so the view element will be removed.
- this.conversionApi.mapper.bindElements(
- fakeElement,
- this.conversionApi.mapper.toViewElement( element )
- );
-
- // Create fake document fragment so a range can be created on fake element.
- const fakeDocumentFragment = new DocumentFragment();
- fakeDocumentFragment.appendChildren( fakeElement );
-
- this.convertRemove( Position.createBefore( element ), Range.createOn( fakeElement ) );
- this.convertInsertion( Range.createOn( element ) );
- }
-
- /**
- * Starts selection conversion.
+ * Starts model selection conversion.
*
* Fires events for given {@link module:engine/model/selection~Selection selection} to start selection conversion.
*
* @fires selection
+ * @fires selectionMarker
* @fires selectionAttribute
* @param {module:engine/model/selection~Selection} selection Selection to convert.
*/
@@ -395,65 +274,70 @@ export default class ModelConversionDispatcher {
}
/**
- * Starts marker conversion.
- *
- * Fires {@link ~#event:addMarker addMarker} or {@link ~#event:removeMarker removeMarker} events for each item
- * in marker's range. If range is collapsed single event is dispatched. See events description for more details.
+ * Converts added marker. Fires {@link #event:addMarker addMarker} event for each item
+ * in marker's range. If range is collapsed single event is dispatched. See event description for more details.
*
* @fires addMarker
- * @fires removeMarker
- * @param {'addMarker'|'removeMarker'} type Change type.
- * @param {String} name Marker name.
- * @param {module:engine/model/range~Range} range Marker range.
+ * @param {String} markerName Marker name.
+ * @param {module:engine/model/range~Range} markerRange Marker range.
*/
- convertMarker( type, name, range ) {
+ convertMarkerAdd( markerName, markerRange ) {
// Do not convert if range is in graveyard or not in the document (e.g. in DocumentFragment).
- if ( !range.root.document || range.root.rootName == '$graveyard' ) {
+ if ( !markerRange.root.document || markerRange.root.rootName == '$graveyard' ) {
return;
}
- // In markers case, event name == consumable name.
- const eventName = type + ':' + name;
+ // In markers' case, event name == consumable name.
+ const eventName = 'addMarker:' + markerName;
// When range is collapsed - fire single event with collapsed range in consumable.
- if ( range.isCollapsed ) {
+ if ( markerRange.isCollapsed ) {
const consumable = new Consumable();
- consumable.add( range, eventName );
+ consumable.add( markerRange, eventName );
this.fire( eventName, {
- markerName: name,
- markerRange: range
+ markerName,
+ markerRange
}, consumable, this.conversionApi );
return;
}
// Create consumable for each item in range.
- const consumable = this._createConsumableForRange( range, eventName );
+ const consumable = this._createConsumableForRange( markerRange, eventName );
// Create separate event for each node in the range.
- for ( const value of range ) {
- const item = value.item;
-
+ for ( const item of markerRange.getItems() ) {
// Do not fire event for already consumed items.
if ( !consumable.test( item, eventName ) ) {
continue;
}
- const data = {
- item,
- range: Range.createFromPositionAndShift( value.previousPosition, value.length ),
- markerName: name,
- markerRange: range
- };
+ const data = { item, range: Range.createOn( item ), markerName, markerRange };
this.fire( eventName, data, consumable, this.conversionApi );
}
}
/**
- * Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with values to consume from given range, assuming that
- * given range has just been inserted to the model.
+ * Fires conversion of marker removal. Fires {@link #event:removeMarker removeMarker} event with provided data.
+ *
+ * @fires removeMarker
+ * @param {String} markerName Marker name.
+ * @param {module:engine/model/range~Range} markerRange Marker range.
+ */
+ convertMarkerRemove( markerName, markerRange ) {
+ // Do not convert if range is in graveyard or not in the document (e.g. in DocumentFragment).
+ if ( !markerRange.root.document || markerRange.root.rootName == '$graveyard' ) {
+ return;
+ }
+
+ this.fire( 'removeMarker:' + markerName, { markerName, markerRange }, this.conversionApi );
+ }
+
+ /**
+ * Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with values to consume from given range,
+ * assuming that the range has just been inserted to the model.
*
* @private
* @param {module:engine/model/range~Range} range Inserted range.
@@ -468,7 +352,7 @@ export default class ModelConversionDispatcher {
consumable.add( item, 'insert' );
for ( const key of item.getAttributeKeys() ) {
- consumable.add( item, 'addAttribute:' + key );
+ consumable.add( item, 'attribute:' + key );
}
}
@@ -476,8 +360,7 @@ export default class ModelConversionDispatcher {
}
/**
- * Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with values of given `type`
- * for each item from given `range`.
+ * Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with values to consume for given range.
*
* @private
* @param {module:engine/model/range~Range} range Affected range.
@@ -523,10 +406,7 @@ export default class ModelConversionDispatcher {
*
* @private
* @fires insert
- * @fires remove
- * @fires addAttribute
- * @fires removeAttribute
- * @fires changeAttribute
+ * @fires attribute
* @param {String} type Event type.
* @param {Object} data Event data.
* @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
@@ -546,8 +426,8 @@ export default class ModelConversionDispatcher {
* Fired for inserted nodes.
*
* `insert` is a namespace for a class of events. Names of actually called events follow this pattern:
- * `insert:`. `name` is either `'$text'` when one or more characters has been inserted or
- * {@link module:engine/model/element~Element#name name} of inserted element.
+ * `insert:`. `name` is either `'$text'`, when {@link module:engine/model/text~Text a text node} has been inserted,
+ * or {@link module:engine/model/element~Element#name name} of inserted element.
*
* This way listeners can either listen to a general `insert` event or specific event (for example `insert:paragraph`).
*
@@ -563,8 +443,8 @@ export default class ModelConversionDispatcher {
* Fired for removed nodes.
*
* `remove` is a namespace for a class of events. Names of actually called events follow this pattern:
- * `remove:`. `name` is either `'$text'` when one or more characters has been removed or the
- * {@link module:engine/model/element~Element#name name} of removed element.
+ * `remove:`. `name` is either `'$text'`, when {@link module:engine/model/text~Text a text node} has been removed,
+ * or the {@link module:engine/model/element~Element#name name} of removed element.
*
* This way listeners can either listen to a general `remove` event or specific event (for example `remove:paragraph`).
*
@@ -577,61 +457,16 @@ export default class ModelConversionDispatcher {
*/
/**
- * Fired when attribute has been added on a node.
+ * Fired when attribute has been added/changed/removed from a node.
*
- * `addAttribute` is a namespace for a class of events. Names of actually called events follow this pattern:
- * `addAttribute::`. `attributeKey` is the key of added attribute. `name` is either `'$text'`
- * if attribute was added on one or more characters, or the {@link module:engine/model/element~Element#name name} of
- * the element on which attribute was added.
+ * `attribute` is a namespace for a class of events. Names of actually called events follow this pattern:
+ * `attribute::`. `attributeKey` is the key of added/changed/removed attribute.
+ * `name` is either `'$text'` if change was on {@link module:engine/model/text~Text a text node},
+ * or the {@link module:engine/model/element~Element#name name} of element which attribute has changed.
*
- * This way listeners can either listen to a general `addAttribute:bold` event or specific event
- * (for example `addAttribute:link:image`).
+ * This way listeners can either listen to a general `attribute:bold` event or specific event (for example `attribute:src:image`).
*
- * @event addAttribute
- * @param {Object} data Additional information about the change.
- * @param {module:engine/model/item~Item} data.item Changed item.
- * @param {module:engine/model/range~Range} data.range Range spanning over changed item.
- * @param {String} data.attributeKey Attribute key.
- * @param {null} data.attributeOldValue Attribute value before the change - always `null`. Kept for the sake of unifying events.
- * @param {*} data.attributeNewValue New attribute value.
- * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
- * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
- */
-
- /**
- * Fired when attribute has been removed from a node.
- *
- * `removeAttribute` is a namespace for a class of events. Names of actually called events follow this pattern:
- * `removeAttribute::`. `attributeKey` is the key of removed attribute. `name` is either `'$text'`
- * if attribute was removed from one or more characters, or the {@link module:engine/model/element~Element#name name} of
- * the element from which attribute was removed.
- *
- * This way listeners can either listen to a general `removeAttribute:bold` event or specific event
- * (for example `removeAttribute:link:image`).
- *
- * @event removeAttribute
- * @param {Object} data Additional information about the change.
- * @param {module:engine/model/item~Item} data.item Changed item.
- * @param {module:engine/model/range~Range} data.range Range spanning over changed item.
- * @param {String} data.attributeKey Attribute key.
- * @param {*} data.attributeOldValue Attribute value before it was removed.
- * @param {null} data.attributeNewValue New attribute value - always `null`. Kept for the sake of unifying events.
- * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
- * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
- */
-
- /**
- * Fired when attribute of a node has been changed.
- *
- * `changeAttribute` is a namespace for a class of events. Names of actually called events follow this pattern:
- * `changeAttribute::`. `attributeKey` is the key of changed attribute. `name` is either `'$text'`
- * if attribute was changed on one or more characters, or the {@link module:engine/model/element~Element#name name} of
- * the element on which attribute was changed.
- *
- * This way listeners can either listen to a general `changeAttribute:link` event or specific event
- * (for example `changeAttribute:link:image`).
- *
- * @event changeAttribute
+ * @event attribute
* @param {Object} data Additional information about the change.
* @param {module:engine/model/item~Item} data.item Changed item.
* @param {module:engine/model/range~Range} data.range Range spanning over changed item.
@@ -646,7 +481,7 @@ export default class ModelConversionDispatcher {
* Fired for {@link module:engine/model/selection~Selection selection} changes.
*
* @event selection
- * @param {module:engine/model/selection~Selection} selection `Selection` instance that is converted.
+ * @param {module:engine/model/selection~Selection} selection Selection that is converted.
* @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
* @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
*/
@@ -656,7 +491,7 @@ export default class ModelConversionDispatcher {
*
* `selectionAttribute` is a namespace for a class of events. Names of actually called events follow this pattern:
* `selectionAttribute:`. `attributeKey` is the key of selection attribute. This way listen can listen to
- * certain attribute, i.e. `addAttribute:bold`.
+ * certain attribute, i.e. `selectionAttribute:bold`.
*
* @event selectionAttribute
* @param {Object} data Additional information about the change.
@@ -669,51 +504,42 @@ export default class ModelConversionDispatcher {
/**
* Fired when a new marker is added to the model.
- * * If marker's range is not collapsed, event is fired separately for each item contained in that range. In this
- * situation, consumable contains all items from that range.
- * * If marker's range is collapsed, single event is fired. In this situation, consumable contains only the collapsed range.
*
* `addMarker` is a namespace for a class of events. Names of actually called events follow this pattern:
* `addMarker:`. By specifying certain marker names, you can make the events even more gradual. For example,
- * markers can be named `foo:abc`, `foo:bar`, then it is possible to listen to `addMarker:foo` or `addMarker:foo:abc` and
+ * if markers are named `foo:abc`, `foo:bar`, then it is possible to listen to `addMarker:foo` or `addMarker:foo:abc` and
* `addMarker:foo:bar` events.
*
+ * If the marker range is not collapsed:
+ * * the event is fired for each item in the marker range one by one,
+ * * consumables object includes each item of the marker range and the consumable value is same as event name.
+ *
+ * If the marker range is collapsed:
+ * * there is only one event,
+ * * consumables object includes marker range with event name.
+ *
* @event addMarker
* @param {Object} data Additional information about the change.
- * @param {module:engine/model/item~Item} [data.item] Item contained in marker's range. Not present if collapsed range
- * is being converted.
- * @param {module:engine/model/range~Range} [data.range] Range spanning over item. Not present if collapsed range
- * is being converted.
- * @param {String} data.markerName Name of the marker.
- * @param {module:engine/model/range~Range} data.markerRange Marker's range spanning on all items.
- * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume. When non-collapsed
- * marker is being converted then consumable contains all items in marker's range. For collapsed markers it contains
- * only marker's range to consume.
+ * @param {module:engine/model/item~Item} data.item Item inside the new marker.
+ * @param {module:engine/model/range~Range} data.range Range spanning over converted item.
+ * @param {module:engine/model/range~Range} data.range Marker range.
+ * @param {String} data.markerName Marker name.
+ * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
* @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
*/
/**
* Fired when marker is removed from the model.
- * * If marker's range is not collapsed, event is fired separately for each item contained in that range. In this
- * situation, consumable contains all items from that range.
- * * If marker's range is collapsed, single event is fired. In this situation, consumable contains only the collapsed range.
*
* `removeMarker` is a namespace for a class of events. Names of actually called events follow this pattern:
* `removeMarker:`. By specifying certain marker names, you can make the events even more gradual. For example,
- * markers can be named `foo:abc`, `foo:bar`, then it is possible to listen to `removeMarker:foo` or `removeMarker:foo:abc` and
+ * if markers are named `foo:abc`, `foo:bar`, then it is possible to listen to `removeMarker:foo` or `removeMarker:foo:abc` and
* `removeMarker:foo:bar` events.
*
* @event removeMarker
* @param {Object} data Additional information about the change.
- * @param {module:engine/model/item~Item} [data.item] Item contained in marker's range. Not present if collapsed range
- * is being converted.
- * @param {module:engine/model/range~Range} [data.range] Range spanning over item. Not present if collapsed range
- * is being converted.
- * @param {String} data.markerName Name of the marker.
- * @param {module:engine/model/range~Range} data.markerRange Whole marker's range.
- * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume. When non-collapsed
- * marker is being converted then consumable contains all items in marker's range. For collapsed markers it contains
- * only marker's range to consume.
+ * @param {module:engine/model/range~Range} data.range Marker range.
+ * @param {String} data.markerName Marker name.
* @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
*/
}
@@ -743,3 +569,9 @@ function shouldMarkerChangeBeConverted( modelPosition, marker, mapper ) {
return !hasCustomHandling;
}
+
+// Checks whether entry change describes changes that happen in graveyard.
+function _isInGraveyard( entry ) {
+ return ( entry.position && entry.position.root.rootName == '$graveyard' ) ||
+ ( entry.range && entry.range.root.rootName == '$graveyard' );
+}
diff --git a/src/dev-utils/enableenginedebug.js b/src/dev-utils/enableenginedebug.js
index 5788c396d..d03245da9 100644
--- a/src/dev-utils/enableenginedebug.js
+++ b/src/dev-utils/enableenginedebug.js
@@ -667,6 +667,9 @@ class DebugPlugin extends Plugin {
modelDocument.on( 'change', () => {
dumpTrees( modelDocument, modelDocument.version );
+ }, { priority: 'lowest' } );
+
+ modelDocument.on( 'changesDone', () => {
dumpTrees( viewDocument, modelDocument.version );
}, { priority: 'lowest' } );
}
diff --git a/src/dev-utils/model.js b/src/dev-utils/model.js
index 4bd78a492..40559f783 100644
--- a/src/dev-utils/model.js
+++ b/src/dev-utils/model.js
@@ -33,7 +33,7 @@ import {
convertCollapsedSelection,
convertSelectionAttribute
} from '../conversion/model-selection-to-view-converters';
-import { insertText, insertElement, wrapItem } from '../conversion/model-to-view-converters';
+import { insertText, insertElement, wrap } from '../conversion/model-to-view-converters';
import isPlainObject from '@ckeditor/ckeditor5-utils/src/lib/lodash/isPlainObject';
/**
@@ -198,7 +198,7 @@ export function stringify( node, selectionOrPositionOrRange = null ) {
mapper.bindElements( node.root, viewDocumentFragment );
modelToView.on( 'insert:$text', insertText() );
- modelToView.on( 'addAttribute', wrapItem( ( value, data ) => {
+ modelToView.on( 'attribute', wrap( ( value, data ) => {
if ( data.item.is( 'textProxy' ) ) {
return new ViewAttributeElement( 'model-text-with-attributes', { [ data.attributeKey ]: stringifyAttributeValue( value ) } );
}
@@ -216,7 +216,7 @@ export function stringify( node, selectionOrPositionOrRange = null ) {
} ) );
// Convert model to view.
- modelToView.convertInsertion( range );
+ modelToView.convertInsert( range );
// Convert model selection to view selection.
if ( selection ) {
diff --git a/src/model/differ.js b/src/model/differ.js
new file mode 100644
index 000000000..641f7511d
--- /dev/null
+++ b/src/model/differ.js
@@ -0,0 +1,867 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module engine/model/differ
+ */
+
+import Position from './position';
+import Range from './range';
+
+/**
+ * Calculates difference between two model states.
+ *
+ * Receives operations that are to be applied on the model document. Marks parts of the model document tree which
+ * are changed and saves those elements state before the change. Then, it compares saved elements with the
+ * changed elements, after all changes are applied on the model document. Calculates the diff between saved
+ * elements and new ones and returns a changes set.
+ */
+export default class Differ {
+ constructor() {
+ /**
+ * A map that stores changes that happened in given element.
+ *
+ * The keys of the map are references to the model elements.
+ * The values of the map are arrays with changes that were done on this element.
+ *
+ * @private
+ * @type {Map}
+ */
+ this._changesInElement = new Map();
+
+ /**
+ * A map that stores "element's children snapshots". A snapshot is representing children of given element before
+ * the first change was applied on that element. Snapshot items are objects with two properties: `name`,
+ * containing element name (or `'$text'` for text node) and `attributes` which is a map of a node's attributes.
+ *
+ * @private
+ * @type {Map}
+ */
+ this._elementSnapshots = new Map();
+
+ /**
+ * A map that stores all changed markers.
+ *
+ * The keys of the map are marker names.
+ * The values of the map are objects with properties `oldRange` and `newRange`. Those holds the marker range
+ * state before and after the change.
+ *
+ * @private
+ * @type {Map}
+ */
+ this._changedMarkers = new Map();
+
+ /**
+ * Stores how many changes has been processed. Used to order changes chronologically. It is important
+ * when changes are sorted.
+ *
+ * @private
+ * @type {Number}
+ */
+ this._changeCount = 0;
+ }
+
+ /**
+ * Buffers given operation. Operation has to be buffered before it is executed.
+ *
+ * Operation type is checked and it is checked which nodes it will affect. Then those nodes are stored in `Differ`
+ * in the state before the operation is executed.
+ *
+ * @param {module:engine/model/operation/operation~Operation} operation Operation to buffer.
+ */
+ bufferOperation( operation ) {
+ switch ( operation.type ) {
+ case 'insert':
+ this._markInsert( operation.position.parent, operation.position.offset, operation.nodes.maxOffset );
+
+ break;
+ case 'addAttribute':
+ case 'removeAttribute':
+ case 'changeAttribute':
+ for ( const item of operation.range.getItems() ) {
+ this._markAttribute( item );
+ }
+
+ break;
+ case 'remove':
+ case 'move':
+ case 'reinsert':
+ this._markRemove( operation.sourcePosition.parent, operation.sourcePosition.offset, operation.howMany );
+ this._markInsert( operation.targetPosition.parent, operation.getMovedRangeStart().offset, operation.howMany );
+
+ break;
+ case 'rename':
+ this._markRemove( operation.position.parent, operation.position.offset, 1 );
+ this._markInsert( operation.position.parent, operation.position.offset, 1 );
+
+ break;
+ }
+ }
+
+ /**
+ * Buffers marker change.
+ *
+ * @param {String} markerName Name of marker which changed.
+ * @param {module:engine/model/range~Range|null} oldRange Marker range before the change or `null` if marker was just created.
+ * @param {module:engine/model/range~Range|null} newRange Marker range after the change or `null` if marker was removed.
+ */
+ bufferMarkerChange( markerName, oldRange, newRange ) {
+ const buffered = this._changedMarkers.get( markerName );
+
+ if ( !buffered ) {
+ this._changedMarkers.set( markerName, {
+ oldRange,
+ newRange
+ } );
+ } else {
+ buffered.newRange = newRange;
+
+ if ( buffered.oldRange == null && buffered.newRange == null ) {
+ // The marker is going to be removed (`newRange == null`) but it did not exist before the change set
+ // (`buffered.oldRange == null`). In this case, do not keep the marker in buffer at all.
+ this._changedMarkers.delete( markerName );
+ }
+ }
+ }
+
+ /**
+ * Returns all markers which should be removed as a result of buffered changes.
+ *
+ * @returns {Array.