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.} Markers to remove. Each array item is an object containing `name` and `range` property. + */ + getMarkersToRemove() { + const result = []; + + for ( const [ name, change ] of this._changedMarkers ) { + if ( change.oldRange != null ) { + result.push( { name, range: change.oldRange } ); + } + } + + return result; + } + + /** + * Returns all markers which should be added as a result of buffered changes. + * + * @returns {Array.} Markers to add. Each array item is an object containing `name` and `range` property. + */ + getMarkersToAdd() { + const result = []; + + for ( const [ name, change ] of this._changedMarkers ) { + if ( change.newRange != null ) { + result.push( { name, range: change.newRange } ); + } + } + + return result; + } + + /** + * Calculates diff between old model tree state (state before the first buffered operations since the last {@link #reset} call) + * and the new model tree state (actual one). Should be called after all buffered operations are executed. + * + * The diff set is returned as an array of diff items, each describing a change done on model. The items are sorted by + * the position on which the change happened. If a position {@link module:engine/model/position~Position#isBefore is before} + * another one, it will be on an earlier index in the diff set. + * + * @returns {Array.} Diff between old and new model tree state. + */ + getChanges() { + // Will contain returned results. + const diffSet = []; + + // Check all changed elements. + for ( const element of this._changesInElement.keys() ) { + // Each item in `this._changesInElement` describes changes of the _children_ of that element. + // If the element itself has been inserted we should skip all the changes in it because the element will be reconverted. + // If the element itself has been removed we should skip all the changes in it because they would be incorrect. + if ( this._isInsertedOrRemoved( element ) ) { + continue; + } + + // Get changes for this element and sort them. + const changes = this._changesInElement.get( element ).sort( ( a, b ) => { + if ( a.offset === b.offset ) { + if ( a.type != b.type ) { + // If there are multiple changes at the same position, "remove" change should be first. + // If the order is different, for example, we would first add some nodes and then removed them + // (instead of the nodes that we should remove). + return a.type == 'remove' ? -1 : 1; + } + + return 0; + } + + return a.offset < b.offset ? -1 : 1; + } ); + + // Get children of this element before any change was applied on it. + const snapshotChildren = this._elementSnapshots.get( element ); + // Get snapshot of current element's children. + const elementChildren = _getChildrenSnapshot( element.getChildren() ); + + // Generate actions basing on changes done on element. + const actions = _generateActionsFromChanges( snapshotChildren.length, changes ); + + let i = 0; // Iterator in `elementChildren` array -- iterates through current children of element. + let j = 0; // Iterator in `snapshotChildren` array -- iterates through old children of element. + + // Process every action. + for ( const action of actions ) { + if ( action === 'i' ) { + // Generate diff item for this element and insert it into the diff set. + diffSet.push( this._getInsertDiff( element, i, elementChildren[ i ].name ) ); + + i++; + } else if ( action === 'r' ) { + // Generate diff item for this element and insert it into the diff set. + diffSet.push( this._getRemoveDiff( element, i, snapshotChildren[ j ].name ) ); + + j++; + } else if ( action === 'a' ) { + // Take attributes from saved and current children. + const elementAttributes = elementChildren[ i ].attributes; + const snapshotAttributes = snapshotChildren[ j ].attributes; + let range; + + if ( elementChildren[ i ].name == '$text' ) { + range = Range.createFromParentsAndOffsets( element, i, element, i + 1 ); + } else { + const index = element.offsetToIndex( i ); + range = Range.createFromParentsAndOffsets( element, i, element.getChild( index ), 0 ); + } + + // Generate diff items for this change (there might be multiple attributes changed and + // there is a single diff for each of them) and insert them into the diff set. + diffSet.push( ...this._getAttributesDiff( range, snapshotAttributes, elementAttributes ) ); + + i++; + j++; + } else { + // `action` is 'equal'. Child not changed. + i++; + j++; + } + } + } + + // Then, sort the changes by the position (change at position before other changes is first). + diffSet.sort( ( a, b ) => { + // If the change is in different root, we don't care much, but we'd like to have all changes in given + // root "together" in the array. So let's just sort them by the root name. It does not matter which root + // will be processed first. + if ( a.position.root != b.position.root ) { + return a.position.root.rootName < b.position.root.rootName ? -1 : 1; + } + + // If change happens at the same position... + if ( a.position.isEqual( b.position ) ) { + // Keep chronological order of operations. + return a.changeCount < b.changeCount ? -1 : 1; + } + + // If positions differ, position "on the left" should be earlier in the result. + return a.position.isBefore( b.position ) ? -1 : 1; + } ); + + // Glue together multiple changes (mostly on text nodes). + for ( let i = 1; i < diffSet.length; i++ ) { + const prevDiff = diffSet[ i - 1 ]; + const thisDiff = diffSet[ i ]; + + // Glue remove changes if they happen on text on same position. + const isConsecutiveTextRemove = + prevDiff.type == 'remove' && thisDiff.type == 'remove' && + prevDiff.name == '$text' && thisDiff.name == '$text' && + prevDiff.position.isEqual( thisDiff.position ); + + // Glue insert changes if they happen on text on consecutive fragments. + const isConsecutiveTextAdd = + prevDiff.type == 'insert' && thisDiff.type == 'insert' && + prevDiff.name == '$text' && thisDiff.name == '$text' && + prevDiff.position.parent == thisDiff.position.parent && + prevDiff.position.offset + prevDiff.length == thisDiff.position.offset; + + // Glue attribute changes if they happen on consecutive fragments and have same key, old value and new value. + const isConsecutiveAttributeChange = + prevDiff.type == 'attribute' && thisDiff.type == 'attribute' && + prevDiff.position.parent == thisDiff.position.parent && + prevDiff.range.isFlat && thisDiff.range.isFlat && + prevDiff.position.offset + prevDiff.length == thisDiff.position.offset && + prevDiff.attributeKey == thisDiff.attributeKey && + prevDiff.attributeOldValue == thisDiff.attributeOldValue && + prevDiff.attributeNewValue == thisDiff.attributeNewValue; + + if ( isConsecutiveTextRemove || isConsecutiveTextAdd || isConsecutiveAttributeChange ) { + diffSet[ i - 1 ].length++; + + if ( isConsecutiveAttributeChange ) { + diffSet[ i - 1 ].range.end = diffSet[ i - 1 ].range.end.getShiftedBy( 1 ); + } + + diffSet.splice( i, 1 ); + i--; + } + } + + // Remove `changeCount` property from diff items. It is used only for sorting and is internal thing. + for ( const item of diffSet ) { + delete item.changeCount; + + if ( item.type == 'attribute' ) { + delete item.position; + delete item.length; + } + } + + this._changeCount = 0; + + return diffSet; + } + + /** + * Resets `Differ`. Removes all buffered changes. + */ + reset() { + this._changesInElement.clear(); + this._elementSnapshots.clear(); + this._changedMarkers.clear(); + } + + /** + * Checks whether given element is inserted or removed or one of its ancestor is inserted or removed. Used to + * filter out sub-changes in elements that are changed itself. + * + * @private + * @param {module:engine/model/element~Element} element Element to check. + * @returns {Boolean} + */ + _isInsertedOrRemoved( element ) { + let parent = element.parent; + + // Check all ancestors of given element. + while ( parent ) { + // Get the checked element's offset. + const offset = element.startOffset; + + if ( this._changesInElement.has( parent ) ) { + const changes = this._changesInElement.get( parent ); + + // If there were changes in that element's ancestor, check all of them. + for ( const change of changes ) { + // Skip attribute changes. We are interested only if the element was inserted or removed. + if ( change.type == 'attribute' ) { + continue; + } + + if ( change.offset <= offset && change.offset + change.howMany > offset ) { + return true; + } + } + } + + // Move up. + parent = parent.parent; + element = element.parent; + } + + return false; + } + + /** + * Saves and handles insert change. + * + * @private + * @param {module:engine/model/element~Element} parent + * @param {Number} offset + * @param {Number} howMany + */ + _markInsert( parent, offset, howMany ) { + const changeItem = { type: 'insert', offset, howMany, count: this._changeCount++ }; + + this._markChange( parent, changeItem ); + } + + /** + * Saves and handles remove change. + * + * @private + * @param {module:engine/model/element~Element} parent + * @param {Number} offset + * @param {Number} howMany + */ + _markRemove( parent, offset, howMany ) { + const changeItem = { type: 'remove', offset, howMany, count: this._changeCount++ }; + + this._markChange( parent, changeItem ); + } + + /** + * Saves and handles attribute change. + * + * @private + * @param {module:engine/model/item~Item} item + */ + _markAttribute( item ) { + const changeItem = { type: 'attribute', offset: item.startOffset, howMany: item.offsetSize, count: this._changeCount++ }; + + this._markChange( item.parent, changeItem ); + } + + /** + * Saves and handles a model change. + * + * @private + * @param {module:engine/model/element~Element} parent + * @param {Object} changeItem + */ + _markChange( parent, changeItem ) { + // First, make a snapshot of this parent's children (it will be made only if it was not made before). + this._makeSnapshot( parent ); + + // Then, get all changes that already were done on the element (empty array if this is the first change). + const changes = this._getChangesForElement( parent ); + + // Then, look through all the changes, and transform them or the new change. + this._handleChange( changeItem, changes ); + + // Add the new change. + changes.push( changeItem ); + + // Remove incorrect changes. During transformation some change might be, for example, included in another. + // In that case, the change will have `howMany` property set to `0` or less. We need to remove those changes. + for ( let i = 0; i < changes.length; i++ ) { + if ( changes[ i ].howMany < 1 ) { + changes.splice( i, 1 ); + + i--; + } + } + } + + /** + * Gets an array of changes that were already saved for given element. + * + * @private + * @param {module:engine/model/element~Element} element + * @returns {Array.} + */ + _getChangesForElement( element ) { + let changes; + + if ( this._changesInElement.has( element ) ) { + changes = this._changesInElement.get( element ); + } else { + changes = []; + + this._changesInElement.set( element, changes ); + } + + return changes; + } + + /** + * Saves a children snapshot for given element. + * + * @private + * @param {module:engine/model/element~Element} element + */ + _makeSnapshot( element ) { + if ( !this._elementSnapshots.has( element ) ) { + this._elementSnapshots.set( element, _getChildrenSnapshot( element.getChildren() ) ); + } + } + + /** + * For given newly saved change, compares it with a change already done on the element and modifies the incoming + * change and/or the old change. + * + * @private + * @param {Object} inc Incoming (new) change. + * @param {Array.} changes Array containing all the changes done on that element. + */ + _handleChange( inc, changes ) { + for ( const old of changes ) { + const incEnd = inc.offset + inc.howMany; + const oldEnd = old.offset + old.howMany; + + if ( inc.type == 'insert' ) { + if ( old.type == 'insert' ) { + if ( inc.offset <= old.offset ) { + old.offset += inc.howMany; + } else if ( inc.offset < oldEnd ) { + old.howMany += inc.howMany; + inc.howMany = 0; + } + } + + if ( old.type == 'remove' ) { + if ( inc.offset < old.offset ) { + old.offset += inc.howMany; + } + } + + if ( old.type == 'attribute' ) { + if ( inc.offset <= old.offset ) { + old.offset += inc.howMany; + } else if ( inc.offset < oldEnd ) { + // This case is more complicated, because attribute change has to be split into two. + // Example (assume that uppercase and lowercase letters mean different attributes): + // + // initial state: abcxyz + // attribute change: aBCXYz + // incoming insert: aBCfooXYz + // + // Change ranges cannot intersect because each item has to be described exactly (it was either + // not changed, inserted, removed, or its attribute was changed). That's why old attribute + // change has to be split and both parts has to be handled separately from now on. + const howMany = old.howMany; + + old.howMany = inc.offset - old.offset; + + // Add the second part of attribute change to the beginning of processed array so it won't + // be processed again in this loop. + changes.unshift( { + type: 'attribute', + offset: incEnd, + howMany: howMany - old.howMany, + count: this._changeCount++ + } ); + } + } + } + + if ( inc.type == 'remove' ) { + if ( old.type == 'insert' ) { + if ( incEnd <= old.offset ) { + old.offset -= inc.howMany; + } else if ( incEnd <= oldEnd ) { + if ( inc.offset < old.offset ) { + const intersectionLength = incEnd - old.offset; + + old.offset = inc.offset; + + old.howMany -= intersectionLength; + inc.howMany -= intersectionLength; + } else { + old.howMany -= inc.howMany; + inc.howMany = 0; + } + } else { + if ( inc.offset <= old.offset ) { + inc.howMany = inc.howMany - old.howMany; + old.howMany = 0; + } else if ( inc.offset < oldEnd ) { + const intersectionLength = oldEnd - inc.offset; + + old.howMany -= intersectionLength; + inc.howMany -= intersectionLength; + } + } + } + + if ( old.type == 'remove' ) { + if ( inc.offset + inc.howMany <= old.offset ) { + old.offset -= inc.howMany; + } else if ( inc.offset < old.offset ) { + old.offset = inc.offset; + old.howMany += inc.howMany; + + inc.howMany = 0; + } + } + + if ( old.type == 'attribute' ) { + if ( incEnd <= old.offset ) { + old.offset -= inc.howMany; + } else if ( inc.offset < old.offset ) { + const intersectionLength = incEnd - old.offset; + + old.offset = inc.offset; + old.howMany -= intersectionLength; + } else if ( inc.offset < oldEnd ) { + if ( incEnd <= oldEnd ) { + // On first sight in this case we don't need to split attribute operation into two. + // However the changes set is later converted to actions (see `_generateActionsFromChanges`). + // For that reason, no two changes may intersect. + // So we cannot have an attribute change that "contains" remove change. + // Attribute change needs to be split. + const howMany = old.howMany; + + old.howMany = inc.offset - old.offset; + + const howManyAfter = howMany - old.howMany - inc.howMany; + + // Add the second part of attribute change to the beginning of processed array so it won't + // be processed again in this loop. + changes.unshift( { + type: 'attribute', + offset: inc.offset, + howMany: howManyAfter, + count: this._changeCount++ + } ); + } else { + old.howMany -= oldEnd - inc.offset; + } + } + } + } + + if ( inc.type == 'attribute' ) { + if ( old.type == 'insert' ) { + if ( inc.offset < old.offset && incEnd > old.offset ) { + if ( incEnd > oldEnd ) { + // This case is similar to a case described when incoming change was insert and old change was attribute. + // See comment above. + // + // This time incoming change is attribute. We need to split incoming change in this case too. + // However this time, the second part of the attribute change needs to be processed further + // because there might be other changes that it collides with. + const attributePart = { + type: 'attribute', + offset: oldEnd, + howMany: incEnd - oldEnd, + count: this._changeCount++ + }; + + this._handleChange( attributePart, changes ); + + changes.push( attributePart ); + } + + inc.howMany = old.offset - inc.offset; + } else if ( inc.offset >= old.offset && inc.offset < oldEnd ) { + if ( incEnd > oldEnd ) { + inc.howMany = incEnd - oldEnd; + inc.offset = oldEnd; + } else { + inc.howMany = 0; + } + } + } + + if ( old.type == 'attribute' ) { + if ( inc.offset >= old.offset && incEnd <= oldEnd ) { + inc.howMany = 0; + } + } + } + } + } + + /** + * Returns an object with a single insert change description. + * + * @private + * @param {module:engine/model/element~Element} parent Element in which change happened. + * @param {Number} offset Offset at which change happened. + * @param {String} name Removed element name or `'$text'` for character. + * @returns {Object} Diff item. + */ + _getInsertDiff( parent, offset, name ) { + return { + type: 'insert', + position: Position.createFromParentAndOffset( parent, offset ), + name, + length: 1, + changeCount: this._changeCount++ + }; + } + + /** + * Returns an object with a single remove change description. + * + * @private + * @param {module:engine/model/element~Element} parent Element in which change happened. + * @param {Number} offset Offset at which change happened. + * @param {String} name Removed element name or `'$text'` for character. + * @returns {Object} Diff item. + */ + _getRemoveDiff( parent, offset, name ) { + return { + type: 'remove', + position: Position.createFromParentAndOffset( parent, offset ), + name, + length: 1, + changeCount: this._changeCount++ + }; + } + + /** + * Returns an array of objects that each is a single attribute change description. + * + * @private + * @param {module:engine/model/range~Range} range Range on which change happened. + * @param {Map} oldAttributes Map, map iterator or compatible object that contains attributes before change. + * @param {Map} newAttributes Map, map iterator or compatible object that contains attributes after change. + * @returns {Array.} Array containing one or more diff items. + */ + _getAttributesDiff( range, oldAttributes, newAttributes ) { + // Results holder. + const diffs = []; + + // Clone new attributes as we will be performing changes on this object. + newAttributes = new Map( newAttributes ); + + // Look through old attributes. + for ( const [ key, oldValue ] of oldAttributes ) { + // Check what is the new value of the attribute (or if it was removed). + const newValue = newAttributes.has( key ) ? newAttributes.get( key ) : null; + + // If values are different (or attribute was removed)... + if ( newValue !== oldValue ) { + // Add diff item. + diffs.push( { + type: 'attribute', + position: range.start, + range: Range.createFromRange( range ), + length: 1, + attributeKey: key, + attributeOldValue: oldValue, + attributeNewValue: newValue, + changeCount: this._changeCount++ + } ); + + // Prevent returning two diff items for the same change. + newAttributes.delete( key ); + } + } + + // Look through new attributes that weren't handled above. + for ( const [ key, newValue ] of newAttributes ) { + // Each of them is a new attribute. Add diff item. + diffs.push( { + type: 'attribute', + position: range.start, + range: Range.createFromRange( range ), + length: 1, + attributeKey: key, + attributeOldValue: null, + attributeNewValue: newValue, + changeCount: this._changeCount++ + } ); + } + + return diffs; + } +} + +// Returns an array that is a copy of passed child list with the exception that text nodes are split to one or more +// objects, each representing one character and attributes set on that character. +function _getChildrenSnapshot( children ) { + const snapshot = []; + + for ( const child of children ) { + if ( child.is( 'text' ) ) { + for ( let i = 0; i < child.data.length; i++ ) { + snapshot.push( { + name: '$text', + attributes: new Map( child.getAttributes() ) + } ); + } + } else { + snapshot.push( { + name: child.name, + attributes: new Map( child.getAttributes() ) + } ); + } + } + + return snapshot; +} + +// Generates array of actions for given changes set. +// It simulates what `diff` function does. +// Generated actions are: +// - 'e' for 'equal' - when item at that position did not change, +// - 'i' for 'insert' - when item at that position was inserted, +// - 'r' for 'remove' - when item at that position was removed, +// - 'a' for 'attribute' - when item at that position has it attributes changed. +// +// Example (assume that uppercase letters have bold attribute, compare with function code): +// +// children before: fooBAR +// children after: foxybAR +// +// changes: type: remove, offset: 1, howMany: 1 +// type: insert, offset: 2, howMany: 2 +// type: attribute, offset: 4, howMany: 1 +// +// expected actions: equal (f), remove (o), equal (o), insert (x), insert (y), attribute (b), equal (A), equal (R) +// +// steps taken by th script: +// +// 1. change = "type: remove, offset: 1, howMany: 1"; offset = 0; oldChildrenHandled = 0 +// 1.1 between this change and the beginning is one not-changed node, fill with one equal action, one old child has been handled +// 1.2 this change removes one node, add one remove action +// 1.3 change last visited `offset` to 1 +// 1.4 since an old child has been removed, one more old child has been handled +// 1.5 actions at this point are: equal, remove +// +// 2. change = "type: insert, offset: 2, howMany: 2"; offset = 1; oldChildrenHandled = 2 +// 2.1 between this change and previous change is one not-changed node, add equal action, another one old children has been handled +// 2.2 this change inserts two nodes, add two insert actions +// 2.3 change last visited offset to the end of the inserted range, that is 4 +// 2.4 actions at this point are: equal, remove, equal, insert, insert +// +// 3. change = "type: attribute, offset: 4, howMany: 1"; offset = 4, oldChildrenHandled = 3 +// 3.1 between this change and previous change are no not-changed nodes +// 3.2 this change changes one node, add one attribute action +// 3.3 change last visited `offset` to the end of change range, that is 5 +// 3.4 since an old child has been changed, one more old child has been handled +// 3.5 actions at this point are: equal, remove, equal, insert, insert, attribute +// +// 4. after loop oldChildrenHandled = 4, oldChildrenLength = 6 (fooBAR is 6 characters) +// 4.1 fill up with two equal actions +// +// The result actions are: equal, remove, equal, insert, insert, attribute, equal, equal. +function _generateActionsFromChanges( oldChildrenLength, changes ) { + const actions = []; + + let offset = 0; + let oldChildrenHandled = 0; + + // Go through all buffered changes. + for ( const change of changes ) { + // First, fill "holes" between changes with "equal" actions. + if ( change.offset > offset ) { + actions.push( ...'e'.repeat( change.offset - offset ).split( '' ) ); + + oldChildrenHandled += change.offset - offset; + } + + // Then, fill up actions accordingly to change type. + if ( change.type == 'insert' ) { + actions.push( ...'i'.repeat( change.howMany ).split( '' ) ); + + // The last handled offset is after inserted range. + offset = change.offset + change.howMany; + } else if ( change.type == 'remove' ) { + actions.push( ...'r'.repeat( change.howMany ).split( '' ) ); + + // The last handled offset is at the position where the nodes were removed. + offset = change.offset; + // We removed `howMany` old nodes, update `oldChildrenHandled`. + oldChildrenHandled += change.howMany; + } else { + actions.push( ...'a'.repeat( change.howMany ).split( '' ) ); + + // The last handled offset isa at the position after the changed range. + offset = change.offset + change.howMany; + // We changed `howMany` old nodes, update `oldChildrenHandled`. + oldChildrenHandled += change.howMany; + } + } + + // Fill "equal" actions at the end of actions set. Use `oldChildrenHandled` to see how many children + // has not been changed / removed at the end of their parent. + if ( oldChildrenHandled < oldChildrenLength ) { + actions.push( ...'e'.repeat( oldChildrenLength - oldChildrenHandled ).split( '' ) ); + } + + return actions; +} diff --git a/src/model/document.js b/src/model/document.js index 4bc575476..9aed135de 100644 --- a/src/model/document.js +++ b/src/model/document.js @@ -96,8 +96,10 @@ export default class Document { * @error document-selection-wrong-position * @param {module:engine/model/range~Range} range */ - throw new CKEditorError( 'document-selection-wrong-position: ' + - 'Range from document selection starts or ends at incorrect position.', { range } ); + throw new CKEditorError( + 'document-selection-wrong-position: Range from document selection starts or ends at incorrect position.', + { range } + ); } } } ); @@ -117,9 +119,12 @@ export default class Document { */ throw new CKEditorError( 'model-document-applyOperation-wrong-version: Only operations with matching versions can be applied.', - { operation } ); + { operation } + ); } - }, { priority: 'high' } ); + + operation._validate(); + }, { priority: 'highest' } ); this.listenTo( model, 'applyOperation', ( evt, args ) => { const operation = args[ 0 ]; @@ -363,7 +368,7 @@ export default class Document { /** * Fired when document changes by applying an operation. * - * There are a few types of change: + * There are several types of change: * * * 'insert' when nodes are inserted, * * 'remove' when nodes are removed, @@ -379,7 +384,7 @@ export default class Document { * * 'changeRootAttribute' when attribute for root changes. * * @event change - * @param {String} type Change type, possible option: 'insert', 'remove', 'reinsert', 'move', 'attribute'. + * @param {String} type Change type. * @param {Object} data Additional information about the change. * @param {module:engine/model/range~Range} [data.range] Range in model containing changed nodes. Note that the range state is * after changes has been done, i.e. for 'remove' the range will be in the {@link #graveyard graveyard root}. diff --git a/src/model/documentfragment.js b/src/model/documentfragment.js index 39ca40cb7..20caec303 100644 --- a/src/model/documentfragment.js +++ b/src/model/documentfragment.js @@ -10,6 +10,7 @@ import NodeList from './nodelist'; import Element from './element'; import Text from './text'; +import TextProxy from './textproxy'; import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable'; /** @@ -218,10 +219,10 @@ export default class DocumentFragment { /** * {@link #insertChildren Inserts} one or more nodes at the end of this document fragment. * - * @param {module:engine/model/node~Node|Iterable.} nodes Nodes to be inserted. + * @param {module:engine/model/item~Item|Iterable.} items Items to be inserted. */ - appendChildren( nodes ) { - this.insertChildren( this.childCount, nodes ); + appendChildren( items ) { + this.insertChildren( this.childCount, items ); } /** @@ -229,10 +230,10 @@ export default class DocumentFragment { * to this document fragment. * * @param {Number} index Index at which nodes should be inserted. - * @param {module:engine/model/node~Node|Iterable.} nodes Nodes to be inserted. + * @param {module:engine/model/item~Item|Iterable.} items Items to be inserted. */ - insertChildren( index, nodes ) { - nodes = normalize( nodes ); + insertChildren( index, items ) { + const nodes = normalize( items ); for ( const node of nodes ) { // If node that is being added to this element is already inside another element, first remove it from the old parent. @@ -306,7 +307,7 @@ export default class DocumentFragment { // Converts strings to Text and non-iterables to arrays. // -// @param {String|module:engine/model/node~Node|Iterable.} +// @param {String|module:engine/model/item~Item|Iterable.} // @return {Iterable.} function normalize( nodes ) { // Separate condition because string is iterable. @@ -321,6 +322,14 @@ function normalize( nodes ) { // Array.from to enable .map() on non-arrays. return Array.from( nodes ) .map( node => { - return typeof node == 'string' ? new Text( node ) : node; + if ( typeof node == 'string' ) { + return new Text( node ); + } + + if ( node instanceof TextProxy ) { + return new Text( node.data, node.getAttributes() ); + } + + return node; } ); } diff --git a/src/model/element.js b/src/model/element.js index e9cd6bc8a..def4ef123 100644 --- a/src/model/element.js +++ b/src/model/element.js @@ -10,6 +10,7 @@ import Node from './node'; import NodeList from './nodelist'; import Text from './text'; +import TextProxy from './textproxy'; import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable'; /** @@ -187,7 +188,7 @@ export default class Element extends Node { /** * {@link module:engine/model/element~Element#insertChildren Inserts} one or more nodes at the end of this element. * - * @param {module:engine/model/node~Node|Iterable.} nodes Nodes to be inserted. + * @param {module:engine/model/item~Item|Iterable.} nodes Nodes to be inserted. */ appendChildren( nodes ) { this.insertChildren( this.childCount, nodes ); @@ -198,10 +199,10 @@ export default class Element extends Node { * to this element. * * @param {Number} index Index at which nodes should be inserted. - * @param {module:engine/model/node~Node|Iterable.} nodes Nodes to be inserted. + * @param {module:engine/model/item~Item|Iterable.} items Items to be inserted. */ - insertChildren( index, nodes ) { - nodes = normalize( nodes ); + insertChildren( index, items ) { + const nodes = normalize( items ); for ( const node of nodes ) { // If node that is being added to this element is already inside another element, first remove it from the old parent. @@ -305,7 +306,7 @@ export default class Element extends Node { // Converts strings to Text and non-iterables to arrays. // -// @param {String|module:engine/model/node~Node|Iterable.} +// @param {String|module:engine/model/item~Item|Iterable.} // @return {Iterable.} function normalize( nodes ) { // Separate condition because string is iterable. @@ -320,6 +321,14 @@ function normalize( nodes ) { // Array.from to enable .map() on non-arrays. return Array.from( nodes ) .map( node => { - return typeof node == 'string' ? new Text( node ) : node; + if ( typeof node == 'string' ) { + return new Text( node ); + } + + if ( node instanceof TextProxy ) { + return new Text( node.data, node.getAttributes() ); + } + + return node; } ); } diff --git a/src/model/operation/attributeoperation.js b/src/model/operation/attributeoperation.js index 2036e8c4e..fba69d6c7 100644 --- a/src/model/operation/attributeoperation.js +++ b/src/model/operation/attributeoperation.js @@ -114,8 +114,7 @@ export default class AttributeOperation extends Operation { /** * @inheritDoc */ - _execute() { - // Validation. + _validate() { for ( const item of this.range.getItems() ) { if ( this.oldValue !== null && !isEqual( item.getAttribute( this.key ), this.oldValue ) ) { /** @@ -147,7 +146,12 @@ export default class AttributeOperation extends Operation { ); } } + } + /** + * @inheritDoc + */ + _execute() { // If value to set is same as old value, don't do anything. if ( !isEqual( this.oldValue, this.newValue ) ) { // Execution. diff --git a/src/model/operation/detachoperation.js b/src/model/operation/detachoperation.js index a647bd0b0..4c68b1c8d 100644 --- a/src/model/operation/detachoperation.js +++ b/src/model/operation/detachoperation.js @@ -62,7 +62,7 @@ export default class DetachOperation extends Operation { /** * @inheritDoc */ - _execute() { + _validate() { if ( this.sourcePosition.root.document ) { /** * Cannot detach document node. @@ -72,7 +72,12 @@ export default class DetachOperation extends Operation { */ throw new CKEditorError( 'detach-operation-on-document-node: Cannot detach document node.' ); } + } + /** + * @inheritDoc + */ + _execute() { const nodes = _remove( Range.createFromPositionAndShift( this.sourcePosition, this.howMany ) ); return { nodes }; diff --git a/src/model/operation/insertoperation.js b/src/model/operation/insertoperation.js index 41442d7c8..2694fd304 100644 --- a/src/model/operation/insertoperation.js +++ b/src/model/operation/insertoperation.js @@ -14,6 +14,7 @@ import RemoveOperation from './removeoperation'; import { _insert, _normalizeNodes } from './utils'; import Text from '../text'; import Element from '../element'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; /** * Operation to insert one or more nodes at given position in the model. @@ -83,6 +84,24 @@ export default class InsertOperation extends Operation { return new RemoveOperation( this.position, this.nodes.maxOffset, gyPosition, this.baseVersion + 1 ); } + /** + * @inheritDoc + */ + _validate() { + const targetElement = this.position.parent; + + if ( !targetElement || targetElement.maxOffset < this.position.offset ) { + /** + * Insertion position is invalid. + * + * @error insert-operation-position-invalid + */ + throw new CKEditorError( + 'insert-operation-position-invalid: Insertion position is invalid.' + ); + } + } + /** * @inheritDoc */ diff --git a/src/model/operation/moveoperation.js b/src/model/operation/moveoperation.js index 6290823d9..13845aa9d 100644 --- a/src/model/operation/moveoperation.js +++ b/src/model/operation/moveoperation.js @@ -131,7 +131,7 @@ export default class MoveOperation extends Operation { /** * @inheritDoc */ - _execute() { + _validate() { const sourceElement = this.sourcePosition.parent; const targetElement = this.targetPosition.parent; const sourceOffset = this.sourcePosition.offset; @@ -183,7 +183,12 @@ export default class MoveOperation extends Operation { } } } + } + /** + * @inheritDoc + */ + _execute() { const range = _move( Range.createFromPositionAndShift( this.sourcePosition, this.howMany ), this.targetPosition ); return { diff --git a/src/model/operation/operation.js b/src/model/operation/operation.js index acbcb3f7f..efc60d469 100644 --- a/src/model/operation/operation.js +++ b/src/model/operation/operation.js @@ -73,8 +73,7 @@ export default class Operation { */ /** - * Executes the operation - modifications described by the operation attributes - * will be applied to the tree model. + * Executes the operation - modifications described by the operation attributes will be applied to the tree model. * * @protected * @method #_execute @@ -83,6 +82,16 @@ export default class Operation { */ } + /** + * Checks whether the operation's parameters are correct and the operation can be correctly executed. Throws + * an error if operation is not valid. + * + * @protected + * @method #_validate + */ + _validate() { + } + /** * Custom toJSON method to solve child-parent circular dependencies. * diff --git a/src/model/operation/reinsertoperation.js b/src/model/operation/reinsertoperation.js index 34ed54572..9b06c5ed8 100644 --- a/src/model/operation/reinsertoperation.js +++ b/src/model/operation/reinsertoperation.js @@ -70,7 +70,9 @@ export default class ReinsertOperation extends MoveOperation { /** * @inheritDoc */ - _execute() { + _validate() { + super._validate(); + if ( !this.sourcePosition.root.document ) { throw new CKEditorError( 'reinsert-operation-on-detached-item: Cannot reinsert detached item.' ); } @@ -78,7 +80,12 @@ export default class ReinsertOperation extends MoveOperation { if ( !this.targetPosition.root.document ) { throw new CKEditorError( 'reinsert-operation-to-detached-parent: Cannot reinsert item to detached parent.' ); } + } + /** + * @inheritDoc + */ + _execute() { return super._execute(); } diff --git a/src/model/operation/removeoperation.js b/src/model/operation/removeoperation.js index 6142d84db..df38e68c5 100644 --- a/src/model/operation/removeoperation.js +++ b/src/model/operation/removeoperation.js @@ -52,7 +52,9 @@ export default class RemoveOperation extends MoveOperation { /** * @inheritDoc */ - _execute() { + _validate() { + super._validate(); + if ( !this.sourcePosition.root.document ) { /** * Item that is going to be removed needs to be a {@link module:engine/model/document~Document document} child. @@ -63,7 +65,12 @@ export default class RemoveOperation extends MoveOperation { */ throw new CKEditorError( 'remove-operation-on-detached-item: Cannot remove detached item.' ); } + } + /** + * @inheritDoc + */ + _execute() { return super._execute(); } diff --git a/src/model/operation/renameoperation.js b/src/model/operation/renameoperation.js index 7f7334a4f..09dfa9c4f 100644 --- a/src/model/operation/renameoperation.js +++ b/src/model/operation/renameoperation.js @@ -86,8 +86,7 @@ export default class RenameOperation extends Operation { /** * @inheritDoc */ - _execute() { - // Validation. + _validate() { const element = this.position.nodeAfter; if ( !( element instanceof Element ) ) { @@ -109,12 +108,15 @@ export default class RenameOperation extends Operation { 'rename-operation-wrong-name: Element to change has different name than operation\'s old name.' ); } + } - // If value to set is same as old value, don't do anything. - if ( element.name != this.newName ) { - // Execution. - element.name = this.newName; - } + /** + * @inheritDoc + */ + _execute() { + const element = this.position.nodeAfter; + + element.name = this.newName; return { element, oldName: this.oldName }; } diff --git a/src/model/operation/rootattributeoperation.js b/src/model/operation/rootattributeoperation.js index 606efe04e..a097d1a03 100644 --- a/src/model/operation/rootattributeoperation.js +++ b/src/model/operation/rootattributeoperation.js @@ -105,7 +105,10 @@ export default class RootAttributeOperation extends Operation { return new RootAttributeOperation( this.root, this.key, this.newValue, this.oldValue, this.baseVersion + 1 ); } - _execute() { + /** + * @inheritDoc + */ + _validate() { if ( this.oldValue !== null && this.root.getAttribute( this.key ) !== this.oldValue ) { /** * The attribute which should be removed does not exists for the given node. @@ -135,7 +138,12 @@ export default class RootAttributeOperation extends Operation { { root: this.root, key: this.key } ); } + } + /** + * @inheritDoc + */ + _execute() { if ( this.newValue !== null ) { this.root.setAttribute( this.key, this.newValue ); } else { diff --git a/src/view/documentfragment.js b/src/view/documentfragment.js index 8df44d9c5..c0e93776f 100644 --- a/src/view/documentfragment.js +++ b/src/view/documentfragment.js @@ -8,6 +8,7 @@ */ import Text from './text'; +import TextProxy from './textproxy'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable'; import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin'; @@ -99,11 +100,11 @@ export default class DocumentFragment { * {@link module:engine/view/documentfragment~DocumentFragment#insertChildren Insert} a child node or a list of child nodes at the end * and sets the parent of these nodes to this fragment. * - * @param {module:engine/view/node~Node|Iterable.} nodes Node or the list of nodes to be inserted. + * @param {module:engine/view/item~Item|Iterable.} items Items to be inserted. * @returns {Number} Number of appended nodes. */ - appendChildren( nodes ) { - return this.insertChildren( this.childCount, nodes ); + appendChildren( items ) { + return this.insertChildren( this.childCount, items ); } /** @@ -140,14 +141,14 @@ export default class DocumentFragment { * this fragment. * * @param {Number} index Position where nodes should be inserted. - * @param {module:engine/view/node~Node|Iterable.} nodes Node or list of nodes to be inserted. + * @param {module:engine/view/item~Item|Iterable.} items Items to be inserted. * @returns {Number} Number of inserted nodes. */ - insertChildren( index, nodes ) { + insertChildren( index, items ) { this._fireChange( 'children', this ); let count = 0; - nodes = normalize( nodes ); + const nodes = normalize( items ); for ( const node of nodes ) { // If node that is being added to this element is already inside another element, first remove it from the old parent. @@ -199,7 +200,7 @@ mix( DocumentFragment, EmitterMixin ); // Converts strings to Text and non-iterables to arrays. // -// @param {String|module:engine/view/node~Node|Iterable.} +// @param {String|module:engine/view/item~Item|Iterable.} // @return {Iterable.} function normalize( nodes ) { // Separate condition because string is iterable. @@ -214,6 +215,14 @@ function normalize( nodes ) { // Array.from to enable .map() on non-arrays. return Array.from( nodes ) .map( node => { - return typeof node == 'string' ? new Text( node ) : node; + if ( typeof node == 'string' ) { + return new Text( node ); + } + + if ( node instanceof TextProxy ) { + return new Text( node.data ); + } + + return node; } ); } diff --git a/src/view/domconverter.js b/src/view/domconverter.js index 82ca210ce..1ce7b596a 100644 --- a/src/view/domconverter.js +++ b/src/view/domconverter.js @@ -1036,7 +1036,7 @@ export default class DomConverter { // ViewContainerElement is found on a way to next ViewText node, so given `node` was first/last // text node in its container element. return null; - } else if ( value.item.is( 'text' ) ) { + } else if ( value.item.is( 'textProxy' ) ) { // Found a text node in the same container element. return value.item; } diff --git a/src/view/element.js b/src/view/element.js index c1dc76fec..dfffae1dc 100644 --- a/src/view/element.js +++ b/src/view/element.js @@ -9,6 +9,7 @@ import Node from './node'; import Text from './text'; +import TextProxy from './textproxy'; import objectToMap from '@ckeditor/ckeditor5-utils/src/objecttomap'; import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable'; import isPlainObject from '@ckeditor/ckeditor5-utils/src/lib/lodash/isPlainObject'; @@ -193,11 +194,11 @@ export default class Element extends Node { * and sets the parent of these nodes to this element. * * @fires module:engine/view/node~Node#change - * @param {module:engine/view/node~Node|Iterable.} nodes Node or the list of nodes to be inserted. + * @param {module:engine/view/item~Item|Iterable.} items Items to be inserted. * @returns {Number} Number of appended nodes. */ - appendChildren( nodes ) { - return this.insertChildren( this.childCount, nodes ); + appendChildren( items ) { + return this.insertChildren( this.childCount, items ); } /** @@ -344,15 +345,15 @@ export default class Element extends Node { * this element. * * @param {Number} index Position where nodes should be inserted. - * @param {module:engine/view/node~Node|Iterable.} nodes Node or the list of nodes to be inserted. + * @param {module:engine/view/item~Item|Iterable.} items Items to be inserted. * @fires module:engine/view/node~Node#change * @returns {Number} Number of inserted nodes. */ - insertChildren( index, nodes ) { + insertChildren( index, items ) { this._fireChange( 'children', this ); let count = 0; - nodes = normalize( nodes ); + const nodes = normalize( items ); for ( const node of nodes ) { // If node that is being added to this element is already inside another element, first remove it from the old parent. @@ -809,7 +810,7 @@ function parseClasses( classesSet, classesString ) { // Converts strings to Text and non-iterables to arrays. // -// @param {String|module:engine/view/node~Node|Iterable.} +// @param {String|module:engine/view/item~Item|Iterable.} // @return {Iterable.} function normalize( nodes ) { // Separate condition because string is iterable. @@ -824,6 +825,14 @@ function normalize( nodes ) { // Array.from to enable .map() on non-arrays. return Array.from( nodes ) .map( node => { - return typeof node == 'string' ? new Text( node ) : node; + if ( typeof node == 'string' ) { + return new Text( node ); + } + + if ( node instanceof TextProxy ) { + return new Text( node.data ); + } + + return node; } ); } diff --git a/src/view/range.js b/src/view/range.js index 991b69340..8468e4808 100644 --- a/src/view/range.js +++ b/src/view/range.js @@ -438,7 +438,9 @@ export default class Range { * @returns {module:engine/view/range~Range} */ static createOn( item ) { - return this.createFromPositionAndShift( Position.createBefore( item ), 1 ); + const size = item.is( 'textProxy' ) ? item.offsetSize : 1; + + return this.createFromPositionAndShift( Position.createBefore( item ), size ); } /** diff --git a/src/view/textproxy.js b/src/view/textproxy.js index 5a48c235f..cb5483ece 100644 --- a/src/view/textproxy.js +++ b/src/view/textproxy.js @@ -84,6 +84,13 @@ export default class TextProxy { this.offsetInText = offsetInText; } + /** + * @inheritDoc + */ + get offsetSize() { + return this.data.length; + } + /** * Flag indicating whether `TextProxy` instance covers only part of the original {@link module:engine/view/text~Text text node} * (`true`) or the whole text node (`false`). diff --git a/src/view/treewalker.js b/src/view/treewalker.js index 838f09280..5a22c8a12 100644 --- a/src/view/treewalker.js +++ b/src/view/treewalker.js @@ -236,7 +236,7 @@ export default class TreeWalker { return this._next(); } else { let charactersCount = node.data.length; - let item = node; + let item; // If text stick out of walker range, we need to cut it and wrap by TextProxy. if ( node == this._boundaryEndParent ) { @@ -244,6 +244,7 @@ export default class TreeWalker { item = new TextProxy( node, 0, charactersCount ); position = Position.createAfter( item ); } else { + item = new TextProxy( node, 0, node.data.length ); // If not just keep moving forward. position.offset++; } @@ -347,7 +348,7 @@ export default class TreeWalker { return this._previous(); } else { let charactersCount = node.data.length; - let item = node; + let item; // If text stick out of walker range, we need to cut it and wrap by TextProxy. if ( node == this._boundaryStartParent ) { @@ -357,6 +358,7 @@ export default class TreeWalker { charactersCount = item.data.length; position = Position.createBefore( item ); } else { + item = new TextProxy( node, 0, node.data.length ); // If not just keep moving backward. position.offset--; } diff --git a/src/view/writer.js b/src/view/writer.js index ffbd49b07..07aa9097a 100644 --- a/src/view/writer.js +++ b/src/view/writer.js @@ -391,8 +391,8 @@ export function clear( range, element ) { if ( item.is( 'element' ) && element.isSimilar( item ) ) { // Create range on this element. rangeToRemove = Range.createOn( item ); - // When range starts inside Text or TextProxy element. - } else if ( !current.nextPosition.isAfter( range.start ) && ( item.is( 'text' ) || item.is( 'textProxy' ) ) ) { + // When range starts inside Text or TextProxy element. + } else if ( !current.nextPosition.isAfter( range.start ) && item.is( 'textProxy' ) ) { // We need to check if parent of this text matches to given element. const parentElement = item.getAncestors().find( ancestor => { return ancestor.is( 'element' ) && element.isSimilar( ancestor ); @@ -480,25 +480,36 @@ export function wrap( range, attribute ) { return range; } - // Range around one element. - if ( range.end.isEqual( range.start.getShiftedBy( 1 ) ) ) { - const node = range.start.nodeAfter; - - if ( node instanceof AttributeElement && wrapAttributeElement( attribute, node ) ) { - return range; - } - } - // Range is inside single attribute and spans on all children. if ( rangeSpansOnAllChildren( range ) && wrapAttributeElement( attribute, range.start.parent ) ) { - const parent = range.start.parent.parent; - const index = range.start.parent.index; + const parent = range.start.parent; - return Range.createFromParentsAndOffsets( parent, index, parent, index + 1 ); + const end = mergeAttributes( Position.createAfter( parent ) ); + const start = mergeAttributes( Position.createBefore( parent ) ); + + return new Range( start, end ); } // Break attributes at range start and end. const { start: breakStart, end: breakEnd } = _breakAttributesRange( range, true ); + + // Range around one element. + if ( breakEnd.isEqual( breakStart.getShiftedBy( 1 ) ) ) { + const node = breakStart.nodeAfter; + + if ( node instanceof AttributeElement && wrapAttributeElement( attribute, node ) ) { + const start = mergeAttributes( breakStart ); + + if ( !start.isEqual( breakStart ) ) { + breakEnd.offset--; + } + + const end = mergeAttributes( breakEnd ); + + return new Range( start, end ); + } + } + const parentContainer = breakStart.parent; // Unwrap children located between break points. @@ -583,7 +594,7 @@ export function wrapPosition( position, attribute ) { * same parent container. * * @param {module:engine/view/range~Range} range - * @param {module:engine/view/attributeelement~AttributeElement} element + * @param {module:engine/view/attributeelement~AttributeElement} attribute */ export function unwrap( range, attribute ) { if ( !( attribute instanceof AttributeElement ) ) { @@ -602,20 +613,29 @@ export function unwrap( range, attribute ) { return range; } + // Break attributes at range start and end. + const { start: breakStart, end: breakEnd } = _breakAttributesRange( range, true ); + // Range around one element - check if AttributeElement can be unwrapped partially when it's not similar. // For example: // unwrap with:

result: - if ( range.end.isEqual( range.start.getShiftedBy( 1 ) ) ) { - const node = range.start.nodeAfter; + if ( breakEnd.isEqual( breakStart.getShiftedBy( 1 ) ) ) { + const node = breakStart.nodeAfter; // Unwrap single attribute element. if ( !attribute.isSimilar( node ) && node instanceof AttributeElement && unwrapAttributeElement( attribute, node ) ) { - return range; + const start = mergeAttributes( breakStart ); + + if ( !start.isEqual( breakStart ) ) { + breakEnd.offset--; + } + + const end = mergeAttributes( breakEnd ); + + return new Range( start, end ); } } - // Break attributes at range start and end. - const { start: breakStart, end: breakEnd } = _breakAttributesRange( range, true ); const parentContainer = breakStart.parent; // Unwrap children located between break points. @@ -628,6 +648,7 @@ export function unwrap( range, attribute ) { if ( !start.isEqual( newRange.start ) ) { newRange.end.offset--; } + const end = mergeAttributes( newRange.end ); return new Range( start, end ); diff --git a/tests/controller/editingcontroller.js b/tests/controller/editingcontroller.js index deea19acc..e61f28a07 100644 --- a/tests/controller/editingcontroller.js +++ b/tests/controller/editingcontroller.js @@ -17,8 +17,6 @@ import buildModelConverter from '../../src/conversion/buildmodelconverter'; import Model from '../../src/model/model'; import ModelPosition from '../../src/model/position'; -import ModelElement from '../../src/model/element'; -import ModelText from '../../src/model/text'; import ModelRange from '../../src/model/range'; import ModelDocumentFragment from '../../src/model/documentfragment'; @@ -146,6 +144,7 @@ describe( 'EditingController', () => { model.schema.registerItem( 'div', '$block' ); buildModelConverter().for( editing.modelToView ).fromElement( 'paragraph' ).toElement( 'p' ); buildModelConverter().for( editing.modelToView ).fromElement( 'div' ).toElement( 'div' ); + buildModelConverter().for( editing.modelToView ).fromMarker( 'marker' ).toHighlight( {} ); // Note: The below code is highly overcomplicated due to #455. model.document.selection.removeAllRanges(); @@ -160,10 +159,12 @@ describe( 'EditingController', () => { model.schema )._children ); - model.enqueueChange( writer => { + model.change( writer => { writer.insert( modelData, model.document.getRoot() ); + model.document.selection.addRange( ModelRange.createFromParentsAndOffsets( - modelRoot.getChild( 0 ), 1, modelRoot.getChild( 0 ), 1 ) ); + modelRoot.getChild( 0 ), 1, modelRoot.getChild( 0 ), 1 ) + ); } ); } ); @@ -180,8 +181,9 @@ describe( 'EditingController', () => { it( 'should convert split', () => { expect( getViewData( editing.view ) ).to.equal( '

f{}oo

bar

' ); - model.enqueueChange( writer => { + model.change( writer => { writer.split( model.document.selection.getFirstPosition() ); + model.document.selection.setRanges( [ ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 1 ), 0, modelRoot.getChild( 1 ), 0 ) ] ); @@ -193,7 +195,7 @@ describe( 'EditingController', () => { it( 'should convert rename', () => { expect( getViewData( editing.view ) ).to.equal( '

f{}oo

bar

' ); - model.enqueueChange( writer => { + model.change( writer => { writer.rename( modelRoot.getChild( 0 ), 'div' ); } ); @@ -201,10 +203,11 @@ describe( 'EditingController', () => { } ); it( 'should convert delete', () => { - model.enqueueChange( writer => { + model.change( writer => { writer.remove( ModelRange.createFromPositionAndShift( model.document.selection.getFirstPosition(), 1 ) ); + model.document.selection.setRanges( [ ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 1, modelRoot.getChild( 0 ), 1 ) ] ); @@ -239,7 +242,7 @@ describe( 'EditingController', () => { } ); it( 'should convert collapsed selection', () => { - model.enqueueChange( () => { + model.change( () => { model.document.selection.setRanges( [ ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 2 ), 1, modelRoot.getChild( 2 ), 1 ) ] ); @@ -249,7 +252,7 @@ describe( 'EditingController', () => { } ); it( 'should convert not collapsed selection', () => { - model.enqueueChange( () => { + model.change( () => { model.document.selection.setRanges( [ ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 2 ), 1, modelRoot.getChild( 2 ), 2 ) ] ); @@ -259,7 +262,7 @@ describe( 'EditingController', () => { } ); it( 'should clear previous selection', () => { - model.enqueueChange( () => { + model.change( () => { model.document.selection.setRanges( [ ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 2 ), 1, modelRoot.getChild( 2 ), 1 ) ] ); @@ -267,7 +270,7 @@ describe( 'EditingController', () => { expect( getViewData( editing.view ) ).to.equal( '

foo

b{}ar

' ); - model.enqueueChange( () => { + model.change( () => { model.document.selection.setRanges( [ ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 2 ), 2, modelRoot.getChild( 2 ), 2 ) ] ); @@ -276,127 +279,116 @@ describe( 'EditingController', () => { expect( getViewData( editing.view ) ).to.equal( '

foo

ba{}r

' ); } ); - it( 'should forward marker events to model conversion dispatcher', () => { - const range = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ); - const markerStub = { - name: 'name', - getRange: () => range - }; + it( 'should convert adding marker', () => { + const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) ); - sinon.spy( editing.modelToView, 'convertMarker' ); + model.change( () => { + model.markers.set( 'marker', range ); + } ); - model.markers.fire( 'add', markerStub ); + expect( getViewData( editing.view, { withoutSelection: true } ) ) + .to.equal( '

foo

bar

' ); + } ); - expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', range ) ).to.be.true; + it( 'should convert removing marker', () => { + const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) ); - model.markers.fire( 'remove', markerStub ); + model.change( () => { + model.markers.set( 'marker', range ); + } ); - expect( editing.modelToView.convertMarker.calledWithExactly( 'removeMarker', 'name', range ) ).to.be.true; + model.change( () => { + model.markers.remove( 'marker' ); + } ); - editing.modelToView.convertMarker.restore(); + expect( getViewData( editing.view, { withoutSelection: true } ) ) + .to.equal( '

foo

bar

' ); } ); - it( 'should forward add marker event if content is inserted into a marker range', () => { - const markerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 ); - const innerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 ); + it( 'should convert changing marker', () => { + const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) ); - model.markers.set( 'name', markerRange ); - - sinon.spy( editing.modelToView, 'convertMarker' ); + model.change( () => { + model.markers.set( 'marker', range ); + } ); - editing.modelToView.convertInsertion( innerRange ); + const range2 = new ModelRange( new ModelPosition( modelRoot, [ 0, 0 ] ), new ModelPosition( modelRoot, [ 0, 2 ] ) ); - expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', innerRange ) ).to.be.true; + model.change( () => { + model.markers.set( 'marker', range2 ); + } ); - editing.modelToView.convertMarker.restore(); + expect( getViewData( editing.view, { withoutSelection: true } ) ) + .to.equal( '

foo

bar

' ); } ); - describe( 'should forward add marker event if inserted content has a marker', () => { - let element, outerRange; - - beforeEach( () => { - element = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) ); - modelRoot.appendChildren( element ); + it( 'should convert insertion into marker', () => { + const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) ); - outerRange = ModelRange.createOn( element ); - - sinon.spy( editing.modelToView, 'convertMarker' ); + model.change( () => { + model.markers.set( 'marker', range ); } ); - afterEach( () => { - editing.modelToView.convertMarker.restore(); + model.change( writer => { + writer.insertText( 'xyz', new ModelPosition( modelRoot, [ 1, 0 ] ) ); } ); - it( 'marker strictly contained', () => { - const markerRange = ModelRange.createFromParentsAndOffsets( element, 1, element, 2 ); - model.markers.set( 'name', markerRange ); + expect( getViewData( editing.view, { withoutSelection: true } ) ) + .to.equal( '

foo

xyz

bar

' ); + } ); - editing.modelToView.convertInsertion( outerRange ); - expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true; - } ); + it( 'should convert move to marker', () => { + const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) ); - it( 'marker starts at same position', () => { - const markerRange = ModelRange.createFromParentsAndOffsets( element, 0, element, 2 ); - model.markers.set( 'name', markerRange ); - editing.modelToView.convertInsertion( outerRange ); - expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true; + model.change( () => { + model.markers.set( 'marker', range ); } ); - it( 'marker ends at same position', () => { - const markerRange = ModelRange.createFromParentsAndOffsets( element, 1, element, 3 ); - model.markers.set( 'name', markerRange ); - editing.modelToView.convertInsertion( outerRange ); - expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true; + model.change( writer => { + writer.move( + new ModelRange( new ModelPosition( modelRoot, [ 2, 2 ] ), new ModelPosition( modelRoot, [ 2, 3 ] ) ), + new ModelPosition( modelRoot, [ 0, 3 ] ) + ); } ); - it( 'marker is same as range', () => { - const markerRange = ModelRange.createFromParentsAndOffsets( element, 0, element, 3 ); - model.markers.set( 'name', markerRange ); - editing.modelToView.convertInsertion( outerRange ); - expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true; - } ); + expect( getViewData( editing.view, { withoutSelection: true } ) ) + .to.equal( '

foor

ba

' ); } ); - it( 'should not start marker conversion if content is not inserted into any marker range', () => { - const markerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 ); - const insertRange = ModelRange.createFromParentsAndOffsets( modelRoot, 6, modelRoot, 8 ); - const consumableMock = { - consume: () => true, - test: () => true - }; - - model.markers.set( 'name', markerRange ); - - sinon.spy( editing.modelToView, 'convertMarker' ); - - editing.modelToView.fire( 'insert', { - range: insertRange - }, consumableMock, { dispatcher: editing.modelToView } ); - - expect( editing.modelToView.convertMarker.called ).to.be.false; - - editing.modelToView.convertMarker.restore(); - } ); + it( 'should convert move from marker', () => { + const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) ); - it( 'should forward add marker event if content is moved into a marker range', () => { - model.enqueueChange( writer => { - writer.appendElement( 'paragraph', model.document.getRoot() ); + model.change( () => { + model.markers.set( 'marker', range ); } ); - const markerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 ); + model.change( writer => { + writer.move( + new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 0, 3 ] ) ), + new ModelPosition( modelRoot, [ 2, 3 ] ) + ); + } ); - model.markers.set( 'name', markerRange ); + expect( getViewData( editing.view, { withoutSelection: true } ) ) + .to.equal( '

f

baroo

' ); + } ); - sinon.spy( editing.modelToView, 'convertMarker' ); + it( 'should convert the whole marker move', () => { + const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 0, 3 ] ) ); - editing.modelToView.convertMove( - ModelPosition.createAt( modelRoot, 3 ), - ModelRange.createOn( modelRoot.getChild( 1 ) ) - ); + model.change( () => { + model.markers.set( 'marker', range ); + } ); - expect( editing.modelToView.convertMarker.calledWith( 'addMarker', 'name' ) ).to.be.true; + model.change( writer => { + writer.move( + new ModelRange( new ModelPosition( modelRoot, [ 0, 0 ] ), new ModelPosition( modelRoot, [ 0, 3 ] ) ), + new ModelPosition( modelRoot, [ 1, 0 ] ) + ); + } ); - editing.modelToView.convertMarker.restore(); + expect( getViewData( editing.view, { withoutSelection: true } ) ) + .to.equal( '

foo

bar

' ); } ); } ); @@ -414,8 +406,9 @@ describe( 'EditingController', () => { editing.destroy(); - model.enqueueChange( writer => { + model.change( writer => { const modelData = parse( 'foo', model.schema ).getChild( 0 ); + writer.insert( modelData, model.document.getRoot() ); } ); diff --git a/tests/conversion/advanced-converters.js b/tests/conversion/advanced-converters.js index e980570a8..114d63717 100644 --- a/tests/conversion/advanced-converters.js +++ b/tests/conversion/advanced-converters.js @@ -10,8 +10,6 @@ import ModelTextProxy from '../../src/model/textproxy'; import ModelRange from '../../src/model/range'; import ModelPosition from '../../src/model/position'; import ModelWalker from '../../src/model/treewalker'; -import ModelWriter from '../../src/model/writer'; -import Batch from '../../src/model/batch'; import ViewElement from '../../src/view/element'; import ViewContainerElement from '../../src/view/containerelement'; @@ -21,47 +19,34 @@ import viewWriter from '../../src/view/writer'; import ViewPosition from '../../src/view/position'; import ViewRange from '../../src/view/range'; -import Mapper from '../../src/conversion/mapper'; -import ModelConversionDispatcher from '../../src/conversion/modelconversiondispatcher'; +import EditingController from '../../src/controller/editingcontroller'; + import ViewConversionDispatcher from '../../src/conversion/viewconversiondispatcher'; import { insertElement, - insertText, - setAttribute, - removeAttribute, - wrapItem, - unwrapItem, - remove, - eventNameToConsumableType + changeAttribute, + wrap } from '../../src/conversion/model-to-view-converters'; import { convertToModelFragment, convertText } from '../../src/conversion/view-to-model-converters'; -import { createRangeOnElementOnly } from '../../tests/model/_utils/utils'; - describe( 'advanced-converters', () => { - let model, modelDoc, modelRoot, viewRoot, mapper, modelDispatcher, viewDispatcher, modelWriter; + let model, modelDoc, modelRoot, viewRoot, modelDispatcher, viewDispatcher; beforeEach( () => { model = new Model(); modelDoc = model.document; modelRoot = modelDoc.createRoot(); - viewRoot = new ViewContainerElement( 'div' ); - mapper = new Mapper(); - mapper.bindElements( modelRoot, viewRoot ); + const editing = new EditingController( model ); - modelDispatcher = new ModelConversionDispatcher( model, { mapper } ); - // Schema is mocked up because we don't care about it in those tests. - viewDispatcher = new ViewConversionDispatcher( model, { schema: { check: () => true } } ); + viewRoot = editing.createRoot( 'div' ); - modelDispatcher.on( 'insert:$text', insertText() ); - modelDispatcher.on( 'remove', remove() ); + viewDispatcher = new ViewConversionDispatcher( model, { schema: { check: () => true } } ); viewDispatcher.on( 'text', convertText() ); viewDispatcher.on( 'documentFragment', convertToModelFragment() ); - // We need to create a model writer to modify model tree in tests. - modelWriter = new ModelWriter( model, new Batch() ); + modelDispatcher = editing.modelToView; } ); function viewAttributesToString( item ) { @@ -131,179 +116,6 @@ describe( 'advanced-converters', () => { return result; } - // Converter for custom `image` element that might have a `caption` element inside which changes - // how the image is displayed in the view: - // - // Model: - // - // [image {src="foo.jpg" title="foo"}] - // └─ [caption] - // ├─ f - // ├─ o - // └─ o - // - // [image {src="bar.jpg" title="bar"}] - // - // View: - // - //
- // ├─ - // └─ - // └─ foo - // - // - describe( 'image with caption converters', () => { - beforeEach( () => { - const modelImageConverter = function( evt, data, consumable, conversionApi ) { - // First, consume the `image` element. - consumable.consume( data.item, 'insert' ); - - // Just create normal image element for the view. - // Maybe it will be "decorated" later. - const viewImage = new ViewContainerElement( 'img' ); - const insertPosition = conversionApi.mapper.toViewPosition( data.range.start ); - - // Check if the `image` element has children. - if ( data.item.childCount > 0 ) { - const modelCaption = data.item.getChild( 0 ); - - // `modelCaption` insertion change is consumed from consumable values. - // It will not be converted by other converters, but it's children (probably some text) will be. - // Through mapping, converters for text will know where to insert contents of `modelCaption`. - if ( consumable.consume( modelCaption, 'insert' ) ) { - const viewCaption = new ViewContainerElement( 'figcaption' ); - - const viewImageHolder = new ViewContainerElement( 'figure', null, [ viewImage, viewCaption ] ); - - conversionApi.mapper.bindElements( modelCaption, viewCaption ); - conversionApi.mapper.bindElements( data.item, viewImageHolder ); - viewWriter.insert( insertPosition, viewImageHolder ); - } - } else { - conversionApi.mapper.bindElements( data.item, viewImage ); - viewWriter.insert( insertPosition, viewImage ); - } - - evt.stop(); - }; - - const modelImageAttributesConverter = function( evt, data, consumable, conversionApi ) { - if ( data.item.name != 'image' ) { - return; - } - - let viewElement = conversionApi.mapper.toViewElement( data.item ); - - if ( viewElement.name == 'figure' ) { - viewElement = viewElement.getChild( 0 ); - } - - consumable.consume( data.item, eventNameToConsumableType( evt.name ) ); - - if ( !data.attributeNewValue ) { - viewElement.removeAttribute( data.attributeKey ); - } else { - viewElement.setAttribute( data.attributeKey, data.attributeNewValue ); - } - - evt.stop(); - }; - - const viewFigureConverter = function( evt, data, consumable, conversionApi ) { - if ( consumable.consume( data.input, { name: true } ) ) { - const modelImage = conversionApi.convertItem( data.input.getChild( 0 ), consumable ); - const modelCaption = conversionApi.convertItem( data.input.getChild( 1 ), consumable ); - - modelImage.appendChildren( modelCaption ); - - data.output = modelImage; - } - }; - - const viewImageConverter = function( evt, data, consumable, conversionApi ) { - if ( consumable.consume( data.input, { name: true } ) ) { - data.output = conversionApi.writer.createElement( 'image', data.input.getAttributes() ); - } - }; - - const viewFigcaptionConverter = function( evt, data, consumable, conversionApi ) { - if ( consumable.consume( data.input, { name: true } ) ) { - const modelCaption = conversionApi.writer.createElement( 'caption' ); - const children = conversionApi.convertChildren( data.input, consumable ); - - conversionApi.writer.append( children, modelCaption ); - - data.output = modelCaption; - } - }; - - modelDispatcher.on( 'insert:image', modelImageConverter ); - modelDispatcher.on( 'addAttribute', modelImageAttributesConverter ); - modelDispatcher.on( 'changeAttribute', modelImageAttributesConverter ); - modelDispatcher.on( 'removeAttribute', modelImageAttributesConverter ); - viewDispatcher.on( 'element:figure', viewFigureConverter ); - viewDispatcher.on( 'element:img', viewImageConverter ); - viewDispatcher.on( 'element:figcaption', viewFigcaptionConverter ); - } ); - - it( 'should convert model images changes without caption to view', () => { - const modelElement = new ModelElement( 'image', { src: 'bar.jpg', title: 'bar' } ); - modelRoot.appendChildren( modelElement ); - modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); - - expect( viewToString( viewRoot ) ).to.equal( '
' ); - - modelElement.setAttribute( 'src', 'new.jpg' ); - modelElement.removeAttribute( 'title' ); - modelDispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'src', 'bar.jpg', 'new.jpg' ); - modelDispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'title', 'bar', null ); - - expect( viewToString( viewRoot ) ).to.equal( '
' ); - } ); - - it( 'should convert model images changes with caption to view', () => { - const modelElement = new ModelElement( 'image', { src: 'foo.jpg', title: 'foo' }, [ - new ModelElement( 'caption', {}, new ModelText( 'foobar' ) ) - ] ); - modelRoot.appendChildren( [ modelElement ] ); - modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); - - expect( viewToString( viewRoot ) ).to.equal( - '
foobar
' - ); - - modelElement.setAttribute( 'src', 'new.jpg' ); - modelElement.removeAttribute( 'title' ); - modelDispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'src', 'bar.jpg', 'new.jpg' ); - modelDispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'title', 'bar', null ); - - expect( viewToString( viewRoot ) ).to.equal( - '
foobar
' - ); - } ); - - it( 'should convert view image to model', () => { - const viewElement = new ViewContainerElement( 'img', { src: 'bar.jpg', title: 'bar' } ); - const modelElement = viewDispatcher.convert( viewElement ); - - expect( modelToString( modelElement ) ).to.equal( '' ); - } ); - - it( 'should convert view figure to model', () => { - const viewElement = new ViewContainerElement( - 'figure', - null, - [ - new ViewContainerElement( 'img', { src: 'bar.jpg', title: 'bar' } ), - new ViewContainerElement( 'figcaption', null, new ViewText( 'foobar' ) ) - ] - ); - const modelElement = viewDispatcher.convert( viewElement ); - - expect( modelToString( modelElement ) ).to.equal( 'foobar' ); - } ); - } ); - // Converter overwrites default attribute converter for `linkHref` and `linkTitle` attribute is set on `quote` element. // // Model: @@ -326,46 +138,11 @@ describe( 'advanced-converters', () => { // └─ foo describe( 'custom attribute handling for given element', () => { beforeEach( () => { - // NORMAL LINK MODEL TO VIEW CONVERTERS - modelDispatcher.on( 'addAttribute:linkHref', wrapItem( value => new ViewAttributeElement( 'a', { href: value } ) ) ); - modelDispatcher.on( 'addAttribute:linkTitle', wrapItem( value => new ViewAttributeElement( 'a', { title: value } ) ) ); - - const changeLinkAttribute = function( elementCreator ) { - return ( evt, data, consumable, conversionApi ) => { - consumable.consume( data.item, eventNameToConsumableType( evt.name ) ); - - const viewRange = conversionApi.mapper.toViewRange( data.range ); - const viewOldA = elementCreator( data.attributeOldValue ); - const viewNewA = elementCreator( data.attributeNewValue ); - - viewWriter.unwrap( viewRange, viewOldA, evt.priority ); - viewWriter.wrap( viewRange, viewNewA, evt.priority ); + // Normal model-to-view converters for links. + modelDispatcher.on( 'attribute:linkHref', wrap( value => new ViewAttributeElement( 'a', { href: value } ) ) ); + modelDispatcher.on( 'attribute:linkTitle', wrap( value => new ViewAttributeElement( 'a', { title: value } ) ) ); - evt.stop(); - }; - }; - - modelDispatcher.on( - 'changeAttribute:linkHref', - changeLinkAttribute( value => new ViewAttributeElement( 'a', { href: value } ) ) - ); - - modelDispatcher.on( - 'changeAttribute:linkTitle', - changeLinkAttribute( value => new ViewAttributeElement( 'a', { title: value } ) ) - ); - - modelDispatcher.on( - 'removeAttribute:linkHref', - unwrapItem( value => new ViewAttributeElement( 'a', { href: value } ) ) - ); - - modelDispatcher.on( - 'removeAttribute:linkTitle', - unwrapItem( value => new ViewAttributeElement( 'a', { title: value } ) ) - ); - - // NORMAL LINK VIEW TO MODEL CONVERTERS + // Normal view-to-model converters for links. viewDispatcher.on( 'element:a', ( evt, data, consumable, conversionApi ) => { if ( consumable.consume( data.input, { name: true, attribute: 'href' } ) ) { if ( !data.output ) { @@ -390,7 +167,7 @@ describe( 'advanced-converters', () => { } } ); - // QUOTE MODEL TO VIEW CONVERTERS + // Model-to-view converter for quote element. modelDispatcher.on( 'insert:quote', ( evt, data, consumable, conversionApi ) => { consumable.consume( data.item, 'insert' ); @@ -399,56 +176,63 @@ describe( 'advanced-converters', () => { conversionApi.mapper.bindElements( data.item, viewElement ); viewWriter.insert( viewPosition, viewElement ); + }, { priority: 'high' } ); - if ( consumable.consume( data.item, 'addAttribute:linkHref' ) ) { - const viewA = new ViewAttributeElement( - 'a', { href: data.item.getAttribute( 'linkHref' ) }, new ViewText( 'see source' ) - ); - - if ( consumable.consume( data.item, 'addAttribute:linkTitle' ) ) { - viewA.setAttribute( 'title', data.item.getAttribute( 'linkTitle' ) ); - } + modelDispatcher.on( 'attribute:linkHref:quote', linkHrefOnQuoteConverter, { priority: 'high' } ); + modelDispatcher.on( 'attribute:linkTitle:quote', linkTitleOnQuoteConverter, { priority: 'high' } ); - viewWriter.insert( new ViewPosition( viewElement, viewElement.childCount ), viewA ); + function linkHrefOnQuoteConverter( evt, data, consumable, conversionApi ) { + if ( !consumable.consume( data.item, 'attribute:linkHref' ) ) { + return; } - evt.stop(); - }, { priority: 'high' } ); + const viewQuote = conversionApi.mapper.toViewElement( data.item ); - const modelChangeLinkAttrQuoteConverter = function( evt, data, consumable, conversionApi ) { - const viewKey = data.attributeKey.substr( 4 ).toLowerCase(); + if ( data.attributeNewValue === null ) { + // Attribute was removed -> remove the view link. + const viewLink = viewQuote.getChild( viewQuote.childCount - 1 ); - consumable.consume( data.item, eventNameToConsumableType( evt.name ) ); + viewWriter.remove( ViewRange.createOn( viewLink ) ); - const viewElement = conversionApi.mapper.toViewElement( data.item ); - const viewA = viewElement.getChild( viewElement.childCount - 1 ); + consumable.consume( data.item, 'attribute:linkTitle' ); + } else if ( data.attributeOldValue === null ) { + // Attribute was added -> add the view link. + const viewLink = new ViewAttributeElement( + 'a', { href: data.item.getAttribute( 'linkHref' ) }, new ViewText( 'see source' ) + ); - if ( data.attributeNewValue !== null ) { - viewA.setAttribute( viewKey, data.attributeNewValue ); + if ( consumable.consume( data.item, 'attribute:linkTitle' ) && data.item.getAttribute( 'linkTitle' ) !== null ) { + viewLink.setAttribute( 'title', data.item.getAttribute( 'linkTitle' ) ); + } + + viewWriter.insert( new ViewPosition( viewQuote, viewQuote.childCount ), viewLink ); } else { - viewA.removeAttribute( viewKey ); + // Attribute has changed -> change the existing view link. + const viewLink = viewQuote.getChild( viewQuote.childCount - 1 ); + viewLink.setAttribute( 'href', data.attributeNewValue ); } + } - evt.stop(); - }; - - modelDispatcher.on( 'changeAttribute:linkHref:quote', modelChangeLinkAttrQuoteConverter, { priority: 'high' } ); - modelDispatcher.on( 'changeAttribute:linkTitle:quote', modelChangeLinkAttrQuoteConverter, { priority: 'high' } ); - - modelDispatcher.on( 'removeAttribute:linkHref:quote', ( evt, data, consumable, conversionApi ) => { - consumable.consume( data.item, eventNameToConsumableType( evt.name ) ); + function linkTitleOnQuoteConverter( evt, data, consumable, conversionApi ) { + if ( !consumable.consume( data.item, 'attribute:linkTitle' ) ) { + return; + } - const viewElement = conversionApi.mapper.toViewElement( data.item ); - const viewA = viewElement.getChild( viewElement.childCount - 1 ); - const aIndex = viewA.index; + const viewQuote = conversionApi.mapper.toViewElement( data.item ); + const viewLink = viewQuote.getChild( viewQuote.childCount - 1 ); - viewWriter.remove( ViewRange.createFromParentsAndOffsets( viewElement, aIndex, viewElement, aIndex + 1 ) ); + if ( !viewLink ) { + return; + } - evt.stop(); - }, { priority: 'high' } ); - modelDispatcher.on( 'removeAttribute:linkTitle:quote', modelChangeLinkAttrQuoteConverter, { priority: 'high' } ); + if ( data.attributeNewValue === null ) { + viewLink.removeAttribute( 'title' ); + } else { + viewLink.setAttribute( 'title', data.attributeNewValue ); + } + } - // QUOTE VIEW TO MODEL CONVERTERS + // View-to-model converter for quote element. viewDispatcher.on( 'element:blockquote', ( evt, data, consumable, conversionApi ) => { if ( consumable.consume( data.input, { name: true } ) ) { data.output = new ModelElement( 'quote' ); @@ -474,43 +258,47 @@ describe( 'advanced-converters', () => { it( 'should convert model text with linkHref and linkTitle to view', () => { const modelText = new ModelText( 'foo', { linkHref: 'foo.html', linkTitle: 'Foo title' } ); - modelRoot.appendChildren( modelText ); - let range = ModelRange.createIn( modelRoot ); + // Let's insert text with link attributes. + model.change( writer => { + writer.insert( + modelText, + new ModelPosition( modelRoot, [ 0 ] ) + ); + } ); - modelDispatcher.convertInsertion( range ); + let range = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 ); expect( viewToString( viewRoot ) ).to.equal( '' ); // Let's change link's attributes. - modelWriter.setAttributes( { - linkHref: 'bar.html', - linkTitle: 'Bar title' - }, range ); - modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkHref', 'foo.html', 'bar.html' ); - modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkTitle', 'Foo title', 'Bar title' ); + model.change( writer => { + writer.setAttribute( 'linkHref', 'bar.html', range ); + writer.setAttribute( 'linkTitle', 'Bar title', range ); + } ); expect( viewToString( viewRoot ) ).to.equal( '' ); - modelWriter.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ) ); - modelDispatcher.convertRemove( - ModelPosition.createFromParentAndOffset( modelRoot, 0 ), - ModelRange.createIn( modelDoc.graveyard ) - ); + // Let's remove a letter from the link. + model.change( writer => { + writer.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '' ); - range = ModelRange.createIn( modelRoot ); - // Let's remove just one attribute. - modelWriter.removeAttribute( 'linkTitle', range ); - modelDispatcher.convertAttribute( 'removeAttribute', range, 'linkTitle', 'Bar title', null ); + model.change( writer => { + range = ModelRange.createIn( modelRoot ); + writer.removeAttribute( 'linkTitle', range ); + } ); expect( viewToString( viewRoot ) ).to.equal( '' ); // Let's remove the other attribute. - modelWriter.removeAttribute( 'linkHref', range ); - modelDispatcher.convertAttribute( 'removeAttribute', range, 'linkHref', 'bar.html', null ); + model.change( writer => { + range = ModelRange.createIn( modelRoot ); + writer.removeAttribute( 'linkHref', range ); + } ); expect( viewToString( viewRoot ) ).to.equal( '
oo
' ); } ); @@ -527,46 +315,45 @@ describe( 'advanced-converters', () => { } ); it( 'should convert quote model element with linkHref and linkTitle attribute to view', () => { + modelDispatcher.on( 'attribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) ); + const modelElement = new ModelElement( 'quote', { linkHref: 'foo.html', linkTitle: 'Foo source' }, new ModelText( 'foo' ) ); - modelRoot.appendChildren( modelElement ); - modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + + // Let's insert a quote element with link attribute. + model.change( writer => { + writer.insert( + modelElement, + new ModelPosition( modelRoot, [ 0 ] ) + ); + } ); let expected = ''; expect( viewToString( viewRoot ) ).to.equal( expected ); - modelDispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) ); - modelDispatcher.on( 'changeAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) ); - modelDispatcher.on( 'removeAttribute:bold', unwrapItem( new ViewAttributeElement( 'strong' ) ) ); - - modelElement.appendChildren( new ModelText( 'bar', { bold: true } ) ); - modelDispatcher.convertInsertion( ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 6 ) ); + // And insert some additional content into it. + model.change( writer => { + writer.insert( + new ModelText( 'bar', { bold: true } ), + new ModelPosition( modelRoot, [ 0, 3 ] ) + ); + } ); expected = ''; expect( viewToString( viewRoot ) ).to.equal( expected ); - modelElement.removeAttribute( 'linkTitle' ); - modelElement.setAttribute( 'linkHref', 'bar.html' ); - - modelDispatcher.convertAttribute( - 'removeAttribute', - createRangeOnElementOnly( modelElement ), - 'linkTitle', - 'Foo source', - null - ); - modelDispatcher.convertAttribute( - 'changeAttribute', - createRangeOnElementOnly( modelElement ), - 'linkHref', - 'foo.html', - 'bar.html' - ); + // Let's change some attributes. + model.change( writer => { + writer.removeAttribute( 'linkTitle', modelElement ); + writer.setAttribute( 'linkHref', 'bar.html', modelElement ); + } ); expected = ''; expect( viewToString( viewRoot ) ).to.equal( expected ); - modelElement.removeAttribute( 'linkHref' ); - modelDispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'linkHref', 'bar.html', null ); + // Let's remove the only attribute connected with link. + model.change( writer => { + writer.removeAttribute( 'linkHref', modelElement ); + } ); expected = '
foobar
'; expect( viewToString( viewRoot ) ).to.equal( expected ); @@ -662,9 +449,7 @@ describe( 'advanced-converters', () => { beforeEach( () => { // "Universal" converters modelDispatcher.on( 'insert', insertElement( data => new ViewContainerElement( data.item.name ) ), { priority: 'lowest' } ); - modelDispatcher.on( 'addAttribute', setAttribute(), { priority: 'lowest' } ); - modelDispatcher.on( 'changeAttribute', setAttribute(), { priority: 'lowest' } ); - modelDispatcher.on( 'removeAttribute', removeAttribute(), { priority: 'lowest' } ); + modelDispatcher.on( 'attribute', changeAttribute(), { priority: 'lowest' } ); viewDispatcher.on( 'element', ( evt, data, consumable, conversionApi ) => { if ( consumable.consume( data.input, { name: true } ) ) { @@ -682,9 +467,7 @@ describe( 'advanced-converters', () => { // "Real" converters -- added with higher priority. Should overwrite the "universal" converters. modelDispatcher.on( 'insert:image', insertElement( new ViewContainerElement( 'img' ) ) ); - modelDispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) ); - modelDispatcher.on( 'changeAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) ); - modelDispatcher.on( 'removeAttribute:bold', unwrapItem( new ViewAttributeElement( 'strong' ) ) ); + modelDispatcher.on( 'attribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) ); viewDispatcher.on( 'element:img', ( evt, data, consumable ) => { if ( consumable.consume( data.input, { name: true } ) ) { @@ -725,7 +508,7 @@ describe( 'advanced-converters', () => { ] ); modelRoot.appendChildren( modelElement ); - modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + modelDispatcher.convertInsert( ModelRange.createIn( modelRoot ) ); expect( viewToString( viewRoot ) ).to.equal( '
' + diff --git a/tests/conversion/buildmodelconverter.js b/tests/conversion/buildmodelconverter.js index b20eb22a4..64d428b66 100644 --- a/tests/conversion/buildmodelconverter.js +++ b/tests/conversion/buildmodelconverter.js @@ -11,27 +11,13 @@ import ModelText from '../../src/model/text'; import ModelRange from '../../src/model/range'; import ModelPosition from '../../src/model/position'; -import ViewDocument from '../../src/view/document'; import ViewElement from '../../src/view/element'; import ViewContainerElement from '../../src/view/containerelement'; import ViewAttributeElement from '../../src/view/attributeelement'; import ViewUIElement from '../../src/view/uielement'; import ViewText from '../../src/view/text'; -import Mapper from '../../src/conversion/mapper'; -import ModelConversionDispatcher from '../../src/conversion/modelconversiondispatcher'; - -import { - insertText, - remove -} from '../../src/conversion/model-to-view-converters'; - -import { - convertCollapsedSelection, - clearAttributes -} from '../../src/conversion/model-selection-to-view-converters'; - -import { createRangeOnElementOnly } from '../../tests/model/_utils/utils'; +import EditingController from '../../src/controller/editingcontroller'; import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; @@ -69,38 +55,31 @@ function viewToString( item ) { } describe( 'Model converter builder', () => { - let dispatcher, mapper, modelDoc, modelRoot, viewDoc, viewRoot, viewSelection, model; + let dispatcher, modelDoc, modelRoot, viewRoot, viewSelection, model, controller; beforeEach( () => { model = new Model(); modelDoc = model.document; - modelRoot = modelDoc.createRoot( 'root', 'root' ); + modelRoot = modelDoc.createRoot(); - viewDoc = new ViewDocument(); - viewRoot = viewDoc.createRoot( 'div' ); - viewSelection = viewDoc.selection; + controller = new EditingController( model ); + controller.createRoot( 'div' ); - mapper = new Mapper(); - mapper.bindElements( modelRoot, viewRoot ); + dispatcher = controller.modelToView; - dispatcher = new ModelConversionDispatcher( model, { mapper, viewSelection } ); - - dispatcher.on( 'insert:$text', insertText() ); - dispatcher.on( 'remove', remove() ); - } ); + viewRoot = controller.view.getRoot(); + viewSelection = controller.view.selection; - afterEach( () => { - viewDoc.destroy(); + buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' ); } ); describe( 'model element to view element conversion', () => { it( 'using passed view element name', () => { - buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' ); - const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) ); - modelRoot.appendChildren( modelElement ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); @@ -109,9 +88,10 @@ describe( 'Model converter builder', () => { buildModelConverter().for( dispatcher ).fromElement( 'image' ).toElement( new ViewContainerElement( 'img' ) ); const modelElement = new ModelElement( 'image' ); - modelRoot.appendChildren( modelElement ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '
' ); } ); @@ -122,32 +102,30 @@ describe( 'Model converter builder', () => { .toElement( data => new ViewContainerElement( 'h' + data.item.getAttribute( 'level' ) ) ); const modelElement = new ModelElement( 'header', { level: 2 }, new ModelText( 'foobar' ) ); - modelRoot.appendChildren( modelElement ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); } ); describe( 'model attribute to view element conversion', () => { - beforeEach( () => { - buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' ); - } ); - it( 'using passed view element name', () => { buildModelConverter().for( dispatcher ).fromAttribute( 'bold' ).toElement( 'strong' ); const modelElement = new ModelText( 'foo', { bold: true } ); - modelRoot.appendChildren( modelElement ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); - modelRoot.removeAttribute( 'bold' ); - - dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'bold', true, null ); + model.change( writer => { + writer.removeAttribute( 'bold', modelElement ); + } ); expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); } ); @@ -156,15 +134,16 @@ describe( 'Model converter builder', () => { buildModelConverter().for( dispatcher ).fromAttribute( 'bold' ).toElement( new ViewAttributeElement( 'strong' ) ); const modelElement = new ModelText( 'foo', { bold: true } ); - modelRoot.appendChildren( modelElement ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); - modelRoot.removeAttribute( 'bold' ); - - dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'bold', true, null ); + model.change( writer => { + writer.removeAttribute( 'bold', modelElement ); + } ); expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); } ); @@ -173,43 +152,38 @@ describe( 'Model converter builder', () => { buildModelConverter().for( dispatcher ).fromAttribute( 'italic' ).toElement( value => new ViewAttributeElement( value ) ); const modelElement = new ModelText( 'foo', { italic: 'em' } ); - modelRoot.appendChildren( modelElement ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); - modelRoot.setAttribute( 'italic', 'i' ); - - dispatcher.convertAttribute( 'changeAttribute', ModelRange.createIn( modelRoot ), 'italic', 'em', 'i' ); + model.change( writer => { + writer.setAttribute( 'italic', 'i', modelElement ); + } ); expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); - modelRoot.removeAttribute( 'italic' ); - - dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'italic', 'i', null ); + model.change( writer => { + writer.removeAttribute( 'italic', modelElement ); + } ); expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); } ); it( 'selection conversion', () => { - // This test requires collapsed range selection converter (breaking attributes) and clearing "artifacts". - dispatcher.on( 'selection', clearAttributes() ); - dispatcher.on( 'selection', convertCollapsedSelection() ); - // Model converter builder should add selection converter. buildModelConverter().for( dispatcher ).fromAttribute( 'italic' ).toElement( value => new ViewAttributeElement( value ) ); - modelRoot.appendChildren( new ModelText( 'foo', { italic: 'em' } ) ); - - // Set collapsed selection after "f". - const position = new ModelPosition( modelRoot, [ 1 ] ); - modelDoc.selection.setRanges( [ new ModelRange( position, position ) ] ); - modelDoc.selection._updateAttributes(); + model.change( writer => { + writer.insert( new ModelText( 'foo', { italic: 'em' } ), ModelPosition.createAt( modelRoot, 0 ) ); - // Convert stuff. - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); - dispatcher.convertSelection( modelDoc.selection, [] ); + // Set collapsed selection after "f". + const position = new ModelPosition( modelRoot, [ 1 ] ); + modelDoc.selection.setRanges( [ new ModelRange( position, position ) ] ); + modelDoc.selection._updateAttributes(); + } ); // Check if view structure is ok. expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); @@ -222,8 +196,9 @@ describe( 'Model converter builder', () => { expect( ranges[ 0 ].start.offset ).to.equal( 1 ); // Change selection attribute, convert it. - modelDoc.selection.setAttribute( 'italic', 'i' ); - dispatcher.convertSelection( modelDoc.selection, [] ); + model.change( () => { + modelDoc.selection.setAttribute( 'italic', 'i' ); + } ); // Check if view structure has changed. expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); @@ -236,16 +211,18 @@ describe( 'Model converter builder', () => { expect( ranges[ 0 ].start.offset ).to.equal( 0 ); // Some more tests checking how selection attributes changes are converted: - modelDoc.selection.removeAttribute( 'italic' ); - dispatcher.convertSelection( modelDoc.selection, [] ); + model.change( () => { + modelDoc.selection.removeAttribute( 'italic' ); + } ); expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); ranges = Array.from( viewSelection.getRanges() ); expect( ranges[ 0 ].start.parent.name ).to.equal( 'div' ); expect( ranges[ 0 ].start.offset ).to.equal( 1 ); - modelDoc.selection.setAttribute( 'italic', 'em' ); - dispatcher.convertSelection( modelDoc.selection, [] ); + model.change( () => { + modelDoc.selection.setAttribute( 'italic', 'em' ); + } ); expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); ranges = Array.from( viewSelection.getRanges() ); @@ -257,27 +234,26 @@ describe( 'Model converter builder', () => { } ); describe( 'model attribute to view attribute conversion', () => { - beforeEach( () => { - buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' ); - } ); - it( 'using default 1-to-1 conversion', () => { buildModelConverter().for( dispatcher ).fromAttribute( 'class' ).toAttribute(); const modelElement = new ModelElement( 'paragraph', { class: 'myClass' }, new ModelText( 'foobar' ) ); - modelRoot.appendChildren( modelElement ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - modelElement.setAttribute( 'class', 'newClass' ); - dispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'myClass', 'newClass' ); + model.change( writer => { + writer.setAttribute( 'class', 'newClass', modelElement ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - modelElement.removeAttribute( 'class' ); - dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'newClass', null ); + model.change( writer => { + writer.removeAttribute( 'class', modelElement ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); @@ -286,19 +262,22 @@ describe( 'Model converter builder', () => { buildModelConverter().for( dispatcher ).fromAttribute( 'theme' ).toAttribute( 'class' ); const modelElement = new ModelElement( 'paragraph', { theme: 'abc' }, new ModelText( 'foobar' ) ); - modelRoot.appendChildren( modelElement ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - modelElement.setAttribute( 'theme', 'xyz' ); - dispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'theme', 'abc', 'xyz' ); + model.change( writer => { + writer.setAttribute( 'theme', 'xyz', modelElement ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - modelElement.removeAttribute( 'theme' ); - dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'theme', 'xyz', null ); + model.change( writer => { + writer.removeAttribute( 'theme', modelElement ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); @@ -307,14 +286,16 @@ describe( 'Model converter builder', () => { buildModelConverter().for( dispatcher ).fromAttribute( 'highlighted' ).toAttribute( 'style', 'background:yellow' ); const modelElement = new ModelElement( 'paragraph', { 'highlighted': true }, new ModelText( 'foobar' ) ); - modelRoot.appendChildren( modelElement ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - modelElement.removeAttribute( 'highlighted' ); - dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'highlighted', true, null ); + model.change( writer => { + writer.removeAttribute( 'highlighted', modelElement ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); @@ -325,19 +306,22 @@ describe( 'Model converter builder', () => { .toAttribute( value => ( { key: 'class', value: value + '-theme' } ) ); const modelElement = new ModelElement( 'paragraph', { theme: 'nice' }, new ModelText( 'foobar' ) ); - modelRoot.appendChildren( modelElement ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - modelElement.setAttribute( 'theme', 'good' ); - dispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'theme', 'nice', 'good' ); + model.change( writer => { + writer.setAttribute( 'theme', 'good', modelElement ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - modelElement.removeAttribute( 'theme' ); - dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'theme', 'good', null ); + model.change( writer => { + writer.removeAttribute( 'theme', modelElement ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); @@ -349,13 +333,10 @@ describe( 'Model converter builder', () => { beforeEach( () => { modelText = new ModelText( 'foobar' ); modelElement = new ModelElement( 'paragraph', null, [ modelText ] ); - modelRoot.appendChildren( modelElement ); - - const viewText = new ViewText( 'foobar' ); - const viewElement = new ViewContainerElement( 'p', null, [ viewText ] ); - viewRoot.appendChildren( viewElement ); - mapper.bindElements( modelElement, viewElement ); + model.change( writer => { + writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) ); + } ); } ); it( 'using passed highlight descriptor object', () => { @@ -365,7 +346,9 @@ describe( 'Model converter builder', () => { attributes: { title: 'highlight title' } } ); - dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) ); + model.change( () => { + model.markers.set( 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '
' + @@ -378,9 +361,9 @@ describe( 'Model converter builder', () => { expect( viewRoot.getChild( 0 ).getChild( 1 ).priority ).to.equal( 3 ); - dispatcher.convertMarker( - 'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) - ); + model.change( () => { + model.markers.remove( 'search' ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); @@ -392,7 +375,9 @@ describe( 'Model converter builder', () => { attributes: { title: 'highlight title' } } ) ); - dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) ); + model.change( () => { + model.markers.set( 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '
' + @@ -405,9 +390,9 @@ describe( 'Model converter builder', () => { expect( viewRoot.getChild( 0 ).getChild( 1 ).priority ).to.equal( 12 ); - dispatcher.convertMarker( - 'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) - ); + model.change( () => { + model.markers.remove( 'search' ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); @@ -417,28 +402,27 @@ describe( 'Model converter builder', () => { class: 'highlight' } ); - dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 ) ); + model.change( () => { + model.markers.set( 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - dispatcher.convertMarker( - 'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 ) - ); + model.change( () => { + model.markers.remove( 'search' ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); it( 'should create converters with provided priority', () => { - buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toHighlight( { - class: 'highlight' - } ); + buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toHighlight( { class: 'highlight' } ); + buildModelConverter().for( dispatcher ).fromMarker( 'search' ).withPriority( 'high' ).toHighlight( { class: 'override' } ); - buildModelConverter().for( dispatcher ).fromMarker( 'search' ).withPriority( 'high' ).toHighlight( { - class: 'override' + model.change( () => { + model.markers.set( 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) ); } ); - dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) ); - expect( viewToString( viewRoot ) ).to.equal( '
' + '

' + @@ -468,13 +452,10 @@ describe( 'Model converter builder', () => { beforeEach( () => { modelText = new ModelText( 'foobar' ); modelElement = new ModelElement( 'paragraph', null, [ modelText ] ); - modelRoot.appendChildren( modelElement ); - - const viewText = new ViewText( 'foobar' ); - const viewElement = new ViewContainerElement( 'p', null, [ viewText ] ); - viewRoot.appendChildren( viewElement ); - mapper.bindElements( modelElement, viewElement ); + model.change( writer => { + writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) ); + } ); } ); describe( 'collapsed range', () => { @@ -485,11 +466,15 @@ describe( 'Model converter builder', () => { it( 'using passed view element name', () => { buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'span' ); - dispatcher.convertMarker( 'addMarker', 'search', range ); + model.change( () => { + model.markers.set( 'search', range ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - dispatcher.convertMarker( 'removeMarker', 'search', range ); + model.change( () => { + model.markers.remove( 'search' ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); @@ -498,11 +483,15 @@ describe( 'Model converter builder', () => { const viewElement = new ViewUIElement( 'span', { class: 'search' } ); buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( viewElement ); - dispatcher.convertMarker( 'addMarker', 'search', range ); + model.change( () => { + model.markers.set( 'search', range ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - dispatcher.convertMarker( 'removeMarker', 'search', range ); + model.change( () => { + model.markers.remove( 'search' ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); @@ -514,11 +503,15 @@ describe( 'Model converter builder', () => { return new ViewUIElement( 'span', { class: className } ); } ); - dispatcher.convertMarker( 'addMarker', 'search:red', range ); + model.change( () => { + model.markers.set( 'search:red', range ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - dispatcher.convertMarker( 'removeMarker', 'search:red', range ); + model.change( () => { + model.markers.remove( 'search:red' ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); @@ -532,11 +525,15 @@ describe( 'Model converter builder', () => { it( 'using passed view element name', () => { buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'span' ); - dispatcher.convertMarker( 'addMarker', 'search', range ); + model.change( () => { + model.markers.set( 'search', range ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - dispatcher.convertMarker( 'removeMarker', 'search', range ); + model.change( () => { + model.markers.remove( 'search' ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); @@ -545,13 +542,17 @@ describe( 'Model converter builder', () => { const viewElement = new ViewUIElement( 'span', { class: 'search' } ); buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( viewElement ); - dispatcher.convertMarker( 'addMarker', 'search', range ); + model.change( () => { + model.markers.set( 'search', range ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - dispatcher.convertMarker( 'removeMarker', 'search', range ); + model.change( () => { + model.markers.remove( 'search' ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); @@ -563,13 +564,17 @@ describe( 'Model converter builder', () => { return new ViewUIElement( 'span', { class: className } ); } ); - dispatcher.convertMarker( 'addMarker', 'search:red', range ); + model.change( () => { + model.markers.set( 'search:red', range ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - dispatcher.convertMarker( 'removeMarker', 'search:red', range ); + model.change( () => { + model.markers.remove( 'search:red' ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); @@ -581,29 +586,17 @@ describe( 'Model converter builder', () => { buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'normal' ); buildModelConverter().for( dispatcher ).fromMarker( 'search' ).withPriority( 'high' ).toElement( 'high' ); - dispatcher.convertMarker( 'addMarker', 'search', range ); + model.change( () => { + model.markers.set( 'search', range ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); - } ); - - describe( 'withPriority', () => { - it( 'should change default converters priority', () => { - buildModelConverter().for( dispatcher ).fromElement( 'custom' ).toElement( 'custom' ); - buildModelConverter().for( dispatcher ).fromElement( 'custom' ).withPriority( 'high' ).toElement( 'other' ); - - const modelElement = new ModelElement( 'custom', null, new ModelText( 'foobar' ) ); - modelRoot.appendChildren( modelElement ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); - - expect( viewToString( viewRoot ) ).to.equal( '
foobar
' ); + it( 'should throw when trying to build model element to view attribute converter', () => { + expect( () => { + buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toAttribute( 'paragraph', true ); + } ).to.throw( CKEditorError, /^build-model-converter-non-attribute-to-attribute/ ); } ); } ); - - it( 'should throw when trying to build model element to view attribute converter', () => { - expect( () => { - buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toAttribute( 'paragraph', true ); - } ).to.throw( CKEditorError, /^build-model-converter-non-attribute-to-attribute/ ); - } ); } ); diff --git a/tests/conversion/mapper.js b/tests/conversion/mapper.js index 70794a50d..f20579b84 100644 --- a/tests/conversion/mapper.js +++ b/tests/conversion/mapper.js @@ -69,6 +69,24 @@ describe( 'Mapper', () => { expect( mapper.toModelElement( viewA ) ).to.be.undefined; expect( mapper.toViewElement( modelA ) ).to.be.undefined; } ); + + it( 'should not remove binding between view and model element if view element got rebound', () => { + const viewA = new ViewElement( 'a' ); + const modelA = new ModelElement( 'a' ); + const modelB = new ModelElement( 'b' ); + + const mapper = new Mapper(); + mapper.bindElements( modelA, viewA ); + mapper.bindElements( modelB, viewA ); + + // `modelA` is still bound to `viewA` even though `viewA` got rebound. + expect( mapper.toViewElement( modelA ) ).to.equal( viewA ); + + mapper.unbindModelElement( modelA ); + + expect( mapper.toViewElement( modelA ) ).to.be.undefined; + expect( mapper.toModelElement( viewA ) ).to.equal( modelB ); + } ); } ); describe( 'unbindViewElement', () => { @@ -87,6 +105,24 @@ describe( 'Mapper', () => { expect( mapper.toModelElement( viewA ) ).to.be.undefined; expect( mapper.toViewElement( modelA ) ).to.be.undefined; } ); + + it( 'should not remove binding between model and view element if model element got rebound', () => { + const viewA = new ViewElement( 'a' ); + const viewB = new ViewElement( 'b' ); + const modelA = new ModelElement( 'a' ); + + const mapper = new Mapper(); + mapper.bindElements( modelA, viewA ); + mapper.bindElements( modelA, viewB ); + + // `viewA` is still bound to `modelA`, even though `modelA` got rebound. + expect( mapper.toModelElement( viewA ) ).to.equal( modelA ); + + mapper.unbindViewElement( viewA ); + + expect( mapper.toModelElement( viewA ) ).to.be.undefined; + expect( mapper.toViewElement( modelA ) ).to.equal( viewB ); + } ); } ); describe( 'Standard mapping', () => { @@ -574,6 +610,18 @@ describe( 'Mapper', () => { } } ); + it( 'should pass isPhantom flag to model-to-view position mapping callback', () => { + const mapper = new Mapper(); + + mapper.on( 'modelToViewPosition', ( evt, data ) => { + expect( data.isPhantom ).to.be.true; + + evt.stop(); + } ); + + mapper.toViewPosition( {}, { isPhantom: true } ); + } ); + describe( 'getModelLength', () => { let mapper; diff --git a/tests/conversion/model-selection-to-view-converters.js b/tests/conversion/model-selection-to-view-converters.js index a5b1bc562..366583612 100644 --- a/tests/conversion/model-selection-to-view-converters.js +++ b/tests/conversion/model-selection-to-view-converters.js @@ -28,9 +28,10 @@ import { import { insertElement, insertText, - wrapItem, + wrap, + highlightElement, highlightText, - highlightElement + removeHighlight } from '../../src/conversion/model-to-view-converters'; import { stringify as stringifyView } from '../../src/dev-utils/view'; @@ -59,12 +60,11 @@ describe( 'model-selection-to-view-converters', () => { dispatcher = new ModelConversionDispatcher( model, { mapper, viewSelection } ); dispatcher.on( 'insert:$text', insertText() ); - dispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) ); + dispatcher.on( 'attribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) ); dispatcher.on( 'addMarker:marker', highlightText( highlightDescriptor ) ); dispatcher.on( 'addMarker:marker', highlightElement( highlightDescriptor ) ); - dispatcher.on( 'removeMarker:marker', highlightText( highlightDescriptor ) ); - dispatcher.on( 'removeMarker:marker', highlightElement( highlightDescriptor ) ); + dispatcher.on( 'removeMarker:marker', removeHighlight( highlightDescriptor ) ); // Default selection converters. dispatcher.on( 'selection', clearAttributes(), { priority: 'low' } ); @@ -231,8 +231,8 @@ describe( 'model-selection-to-view-converters', () => { viewRoot.removeChildren( 0, viewRoot.childCount ); // Convert model to view. - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); - dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() ); + dispatcher.convertInsert( ModelRange.createIn( modelRoot ) ); + dispatcher.convertMarkerAdd( marker.name, marker.getRange() ); const markers = Array.from( model.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) ); dispatcher.convertSelection( modelSelection, markers ); @@ -258,8 +258,8 @@ describe( 'model-selection-to-view-converters', () => { viewRoot.removeChildren( 0, viewRoot.childCount ); // Convert model to view. - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); - dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() ); + dispatcher.convertInsert( ModelRange.createIn( modelRoot ) ); + dispatcher.convertMarkerAdd( marker.name, marker.getRange() ); const markers = Array.from( model.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) ); dispatcher.convertSelection( modelSelection, markers ); @@ -283,8 +283,8 @@ describe( 'model-selection-to-view-converters', () => { viewRoot.removeChildren( 0, viewRoot.childCount ); // Convert model to view. - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); - dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() ); + dispatcher.convertInsert( ModelRange.createIn( modelRoot ) ); + dispatcher.convertMarkerAdd( marker.name, marker.getRange() ); const markers = Array.from( model.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) ); dispatcher.convertSelection( modelSelection, markers ); @@ -296,6 +296,7 @@ describe( 'model-selection-to-view-converters', () => { it( 'in marker - should merge with the rest of attribute elements', () => { dispatcher.on( 'addMarker:marker2', highlightText( data => ( { 'class': data.markerName } ) ) ); + dispatcher.on( 'addMarker:marker2', highlightElement( data => ( { 'class': data.markerName } ) ) ); dispatcher.on( 'selectionMarker:marker2', convertSelectionMarker( data => ( { 'class': data.markerName } ) ) ); setModelData( model, 'foobar' ); @@ -307,8 +308,8 @@ describe( 'model-selection-to-view-converters', () => { viewRoot.removeChildren( 0, viewRoot.childCount ); // Convert model to view. - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); - dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() ); + dispatcher.convertInsert( ModelRange.createIn( modelRoot ) ); + dispatcher.convertMarkerAdd( marker.name, marker.getRange() ); const markers = Array.from( model.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) ); dispatcher.convertSelection( modelSelection, markers ); @@ -332,8 +333,8 @@ describe( 'model-selection-to-view-converters', () => { viewRoot.removeChildren( 0, viewRoot.childCount ); // Convert model to view. - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); - dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() ); + dispatcher.convertInsert( ModelRange.createIn( modelRoot ) ); + dispatcher.convertMarkerAdd( marker.name, marker.getRange() ); const markers = Array.from( model.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) ); dispatcher.convertSelection( modelSelection, markers ); @@ -372,7 +373,7 @@ describe( 'model-selection-to-view-converters', () => { modelSelection.setAttribute( 'bold', true ); // Convert model to view. - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + dispatcher.convertInsert( ModelRange.createIn( modelRoot ) ); // Add ui element to view. const uiElement = new ViewUIElement( 'span' ); @@ -393,7 +394,7 @@ describe( 'model-selection-to-view-converters', () => { modelSelection.setAttribute( 'bold', true ); // Convert model to view. - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + dispatcher.convertInsert( ModelRange.createIn( modelRoot ) ); // Add ui element to view. const uiElement = new ViewUIElement( 'span' ); @@ -467,7 +468,7 @@ describe( 'model-selection-to-view-converters', () => { describe( 'clearAttributes', () => { it( 'should remove all ranges before adding new range', () => { dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) ); - dispatcher.on( 'addAttribute:style', wrapItem( new ViewAttributeElement( 'b' ) ) ); + dispatcher.on( 'attribute:style', wrap( new ViewAttributeElement( 'b' ) ) ); test( [ 3, 3 ], @@ -489,7 +490,7 @@ describe( 'model-selection-to-view-converters', () => { it( 'should do nothing if the attribute element had been already removed', () => { dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) ); - dispatcher.on( 'addAttribute:style', wrapItem( new ViewAttributeElement( 'b' ) ) ); + dispatcher.on( 'attribute:style', wrap( new ViewAttributeElement( 'b' ) ) ); test( [ 3, 3 ], @@ -536,7 +537,7 @@ describe( 'model-selection-to-view-converters', () => { } dispatcher.on( 'selectionAttribute:theme', convertSelectionAttribute( themeElementCreator ) ); - dispatcher.on( 'addAttribute:theme', wrapItem( themeElementCreator ) ); + dispatcher.on( 'attribute:theme', wrap( themeElementCreator ) ); dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) ); } ); @@ -736,7 +737,7 @@ describe( 'model-selection-to-view-converters', () => { viewRoot.removeChildren( 0, viewRoot.childCount ); // Convert model to view. - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + dispatcher.convertInsert( ModelRange.createIn( modelRoot ) ); dispatcher.convertSelection( modelSelection, [] ); // Stringify view and check if it is same as expected. diff --git a/tests/conversion/model-to-view-converters.js b/tests/conversion/model-to-view-converters.js index a65914606..faf236514 100644 --- a/tests/conversion/model-to-view-converters.js +++ b/tests/conversion/model-to-view-converters.js @@ -3,8 +3,6 @@ * For licensing, see LICENSE.md. */ -import ModelWriter from '../../src/model/writer'; -import Batch from '../../src/model/batch'; import Model from '../../src/model/model'; import ModelElement from '../../src/model/element'; import ModelText from '../../src/model/text'; @@ -17,41 +15,38 @@ import ViewAttributeElement from '../../src/view/attributeelement'; import ViewUIElement from '../../src/view/uielement'; import ViewText from '../../src/view/text'; -import Mapper from '../../src/conversion/mapper'; -import ModelConversionDispatcher from '../../src/conversion/modelconversiondispatcher'; import { insertElement, - insertText, insertUIElement, - setAttribute, - removeAttribute, - wrapItem, - unwrapItem, - remove, + changeAttribute, + wrap, removeUIElement, - highlightText, highlightElement, + highlightText, + removeHighlight, createViewElementFromHighlightDescriptor } from '../../src/conversion/model-to-view-converters'; -import { createRangeOnElementOnly } from '../../tests/model/_utils/utils'; +import EditingController from '../../src/controller/editingcontroller'; describe( 'model-to-view-converters', () => { - let dispatcher, model, modelDoc, modelRoot, modelWriter, mapper, viewRoot; + let dispatcher, modelDoc, modelRoot, viewRoot, controller, modelRootStart, model; beforeEach( () => { model = new Model(); modelDoc = model.document; modelRoot = modelDoc.createRoot(); - viewRoot = new ViewContainerElement( 'div' ); - mapper = new Mapper(); - mapper.bindElements( modelRoot, viewRoot ); + controller = new EditingController( model ); + controller.createRoot( 'div' ); - dispatcher = new ModelConversionDispatcher( model, { mapper } ); + viewRoot = controller.view.getRoot(); + dispatcher = controller.modelToView; - // As an util for modifying model tree. - modelWriter = new ModelWriter( model, new Batch() ); + dispatcher.on( 'insert:paragraph', insertElement( () => new ViewContainerElement( 'p' ) ) ); + dispatcher.on( 'attribute:class', changeAttribute() ); + + modelRootStart = ModelPosition.createAt( modelRoot, 0 ); } ); function viewAttributesToString( item ) { @@ -87,279 +82,31 @@ describe( 'model-to-view-converters', () => { return result; } - describe( 'highlightText()', () => { - let modelElement1, modelElement2, markerRange; - const highlightDescriptor = { - class: 'highlight-class', - priority: 7, - attributes: { title: 'title' } - }; - - beforeEach( () => { - const modelText1 = new ModelText( 'foo' ); - modelElement1 = new ModelElement( 'paragraph', null, modelText1 ); - const modelText2 = new ModelText( 'bar' ); - modelElement2 = new ModelElement( 'paragraph', null, modelText2 ); - - modelRoot.appendChildren( modelElement1 ); - modelRoot.appendChildren( modelElement2 ); - dispatcher.on( 'insert:paragraph', insertElement( () => new ViewContainerElement( 'p' ) ) ); - dispatcher.on( 'insert:$text', insertText() ); - - markerRange = ModelRange.createIn( modelRoot ); - } ); - - it( 'should wrap and unwrap text nodes', () => { - dispatcher.on( 'addMarker:marker', highlightText( highlightDescriptor ) ); - dispatcher.on( 'removeMarker:marker', highlightText( highlightDescriptor ) ); - dispatcher.convertInsertion( markerRange ); - - model.markers.set( 'marker', markerRange ); - dispatcher.convertMarker( 'addMarker', 'marker', markerRange ); - - expect( viewToString( viewRoot ) ).to.equal( - '
' + - '

' + - 'foo' + - '

' + - '

' + - 'bar' + - '

' + - '
' - ); - - dispatcher.convertMarker( 'removeMarker', 'marker', markerRange ); - - expect( viewToString( viewRoot ) ).to.equal( '

foo

bar

' ); - } ); - - it( 'should not convert marker on elements already consumed', () => { - const newDescriptor = { class: 'override-class' }; - - dispatcher.on( 'addMarker:marker', highlightText( highlightDescriptor ) ); - dispatcher.on( 'addMarker:marker', highlightText( newDescriptor ), { priority: 'high' } ); - dispatcher.on( 'removeMarker:marker', highlightText( highlightDescriptor ) ); - dispatcher.on( 'removeMarker:marker', highlightText( newDescriptor ), { priority: 'high' } ); - dispatcher.convertInsertion( markerRange ); - - model.markers.set( 'marker', markerRange ); - dispatcher.convertMarker( 'addMarker', 'marker', markerRange ); - - expect( viewToString( viewRoot ) ).to.equal( - '
' + - '

' + - 'foo' + - '

' + - '

' + - 'bar' + - '

' + - '
' - ); - - dispatcher.convertMarker( 'removeMarker', 'marker', markerRange ); - - expect( viewToString( viewRoot ) ).to.equal( '

foo

bar

' ); - } ); - - it( 'should do nothing if descriptor is not provided', () => { - dispatcher.on( 'addMarker:marker', highlightText( () => null ) ); - dispatcher.on( 'removeMarker:marker', highlightText( () => null ) ); - - dispatcher.convertInsertion( markerRange ); - - model.markers.set( 'marker', markerRange ); - dispatcher.convertMarker( 'addMarker', 'marker', markerRange ); - - expect( viewToString( viewRoot ) ).to.equal( '

foo

bar

' ); - dispatcher.convertMarker( 'removeMarker', 'marker', markerRange ); - expect( viewToString( viewRoot ) ).to.equal( '

foo

bar

' ); - } ); - } ); - - describe( 'highlightElement()', () => { - let modelElement1, modelElement2, markerRange; - const highlightDescriptor = { - class: 'highlight-class', - priority: 7, - attributes: { title: 'title' }, - id: 'customId' - }; - - beforeEach( () => { - const modelText1 = new ModelText( 'foo' ); - modelElement1 = new ModelElement( 'paragraph', null, modelText1 ); - const modelText2 = new ModelText( 'bar' ); - modelElement2 = new ModelElement( 'paragraph', null, modelText2 ); - - modelRoot.appendChildren( modelElement1 ); - modelRoot.appendChildren( modelElement2 ); - dispatcher.on( 'insert:paragraph', insertElement( () => new ViewContainerElement( 'p' ) ) ); - dispatcher.on( 'insert:$text', insertText() ); - - markerRange = ModelRange.createIn( modelRoot ); - } ); - - it( 'should use addHighlight and removeHighlight on elements and not convert children nodes', () => { - dispatcher.on( 'addMarker:marker', highlightElement( highlightDescriptor ) ); - dispatcher.on( 'removeMarker:marker', highlightElement( highlightDescriptor ) ); - dispatcher.on( 'insert:paragraph', insertElement( data => { - // Use special converter only for first paragraph. - if ( data.item == modelElement2 ) { - return; - } - - const viewContainer = new ViewContainerElement( 'p' ); - - viewContainer.setCustomProperty( 'addHighlight', ( element, descriptor ) => { - element.addClass( 'highlight-own-class' ); - - expect( descriptor ).to.equal( highlightDescriptor ); - } ); - - viewContainer.setCustomProperty( 'removeHighlight', ( element, id ) => { - element.removeClass( 'highlight-own-class' ); - - expect( id ).to.equal( highlightDescriptor.id ); - } ); - - return viewContainer; - } ), { priority: 'high' } ); - - dispatcher.convertInsertion( markerRange ); - model.markers.set( 'marker', markerRange ); - dispatcher.convertMarker( 'addMarker', 'marker', markerRange ); - - expect( viewToString( viewRoot ) ).to.equal( - '
' + - '

' + - 'foo' + - '

' + - '

' + - 'bar' + - '

' + - '
' - ); - - dispatcher.convertMarker( 'removeMarker', 'marker', markerRange ); - - expect( viewToString( viewRoot ) ).to.equal( '

foo

bar

' ); - } ); - - it( 'should not convert marker on elements already consumed', () => { - const newDescriptor = { class: 'override-class' }; - - dispatcher.on( 'addMarker:marker', highlightElement( highlightDescriptor ) ); - dispatcher.on( 'removeMarker:marker', highlightElement( highlightDescriptor ) ); - - dispatcher.on( 'addMarker:marker', highlightElement( newDescriptor ), { priority: 'high' } ); - dispatcher.on( 'removeMarker:marker', highlightElement( newDescriptor ), { priority: 'high' } ); - - dispatcher.on( 'insert:paragraph', insertElement( () => { - const element = new ViewContainerElement( 'p' ); - const descriptors = new Map(); - - element.setCustomProperty( 'addHighlight', ( element, data ) => { - descriptors.set( data.id, data ); - element.addClass( data.class ); - } ); - element.setCustomProperty( 'removeHighlight', ( element, id ) => element.removeClass( descriptors.get( id ).class ) ); - - return element; - } ), { priority: 'high' } ); - - dispatcher.convertInsertion( markerRange ); - model.markers.set( 'marker', markerRange ); - dispatcher.convertMarker( 'addMarker', 'marker', markerRange ); - - expect( viewToString( viewRoot ) ).to.equal( - '
' + - '

' + - 'foo' + - '

' + - '

' + - 'bar' + - '

' + - '
' - ); - - dispatcher.convertMarker( 'removeMarker', 'marker', markerRange ); - - expect( viewToString( viewRoot ) ).to.equal( '

foo

bar

' ); - } ); - - it( 'should use provide default priority and id if not provided', () => { - const highlightDescriptor = { class: 'highlight-class' }; - - dispatcher.on( 'addMarker:marker', highlightElement( highlightDescriptor ) ); - dispatcher.on( 'removeMarker:marker', highlightElement( highlightDescriptor ) ); - dispatcher.on( 'insert:paragraph', insertElement( data => { - // Use special converter only for first paragraph. - if ( data.item == modelElement2 ) { - return; - } - - const viewContainer = new ViewContainerElement( 'p' ); - - viewContainer.setCustomProperty( 'addHighlight', ( element, descriptor ) => { - expect( descriptor.priority ).to.equal( 10 ); - expect( descriptor.id ).to.equal( 'marker:foo-bar-baz' ); - } ); - - viewContainer.setCustomProperty( 'removeHighlight', ( element, descriptor ) => { - expect( descriptor.priority ).to.equal( 10 ); - expect( descriptor.id ).to.equal( 'marker:foo-bar-baz' ); - } ); - - return viewContainer; - } ), { priority: 'high' } ); - - dispatcher.convertInsertion( markerRange ); - model.markers.set( 'marker', markerRange ); - dispatcher.convertMarker( 'addMarker', 'marker:foo-bar-baz', markerRange ); - } ); - - it( 'should do nothing if descriptor is not provided', () => { - dispatcher.on( 'addMarker:marker', highlightElement( () => null ) ); - dispatcher.on( 'removeMarker:marker', highlightElement( () => null ) ); - - dispatcher.convertInsertion( markerRange ); - - model.markers.set( 'marker', markerRange ); - dispatcher.convertMarker( 'addMarker', 'marker', markerRange ); - - expect( viewToString( viewRoot ) ).to.equal( '

foo

bar

' ); - dispatcher.convertMarker( 'removeMarker', 'marker', markerRange ); - expect( viewToString( viewRoot ) ).to.equal( '

foo

bar

' ); - } ); - } ); - describe( 'insertText', () => { it( 'should convert text insertion in model to view text', () => { - modelRoot.appendChildren( new ModelText( 'foobar' ) ); - dispatcher.on( 'insert:$text', insertText() ); - - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( new ModelText( 'foobar' ), modelRootStart ); + } ); expect( viewToString( viewRoot ) ).to.equal( '
foobar
' ); } ); it( 'should support unicode', () => { - modelRoot.appendChildren( new ModelText( 'நிலைக்கு' ) ); - dispatcher.on( 'insert:$text', insertText() ); - - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( new ModelText( 'நிலைக்கு' ), modelRootStart ); + } ); expect( viewToString( viewRoot ) ).to.equal( '
நிலைக்கு
' ); } ); it( 'should be possible to override it', () => { - modelRoot.appendChildren( new ModelText( 'foobar' ) ); - dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'insert:$text', ( evt, data, consumable ) => { consumable.consume( data.item, 'insert' ); }, { priority: 'high' } ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( new ModelText( 'foobar' ), modelRootStart ); + } ); expect( viewToString( viewRoot ) ).to.equal( '
' ); } ); @@ -368,90 +115,61 @@ describe( 'model-to-view-converters', () => { describe( 'insertElement', () => { it( 'should convert element insertion in model to and map positions for future converting', () => { const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) ); - const viewElement = new ViewContainerElement( 'p' ); - - modelRoot.appendChildren( modelElement ); - dispatcher.on( 'insert:paragraph', insertElement( viewElement ) ); - dispatcher.on( 'insert:$text', insertText() ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( modelElement, modelRootStart ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); it( 'should take view element function generator as a parameter', () => { const elementGenerator = ( data, consumable ) => { - if ( consumable.consume( data.item, 'addAttribute:nice' ) ) { + if ( consumable.consume( data.item, 'attribute:nice' ) ) { return new ViewContainerElement( 'div' ); - } else { - return new ViewContainerElement( 'p' ); } - }; - const niceP = new ModelElement( 'myParagraph', { nice: true }, new ModelText( 'foo' ) ); - const badP = new ModelElement( 'myParagraph', null, new ModelText( 'bar' ) ); - - modelRoot.appendChildren( [ niceP, badP ] ); - - dispatcher.on( 'insert:myParagraph', insertElement( elementGenerator ) ); - dispatcher.on( 'insert:$text', insertText() ); - - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); - - expect( viewToString( viewRoot ) ).to.equal( '
foo

bar

' ); - } ); - - it( 'should not convert and not consume if creator function returned null', () => { - const elementGenerator = () => null; - const modelP = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) ); + // Test if default converter will be fired for paragraph, if `null` is returned and consumable was not consumed. + return null; + }; - modelRoot.appendChildren( [ modelP ] ); + dispatcher.on( 'insert:paragraph', insertElement( elementGenerator ), { priority: 'high' } ); - sinon.spy( dispatcher, 'fire' ); + const niceP = new ModelElement( 'paragraph', { nice: true }, new ModelText( 'foo' ) ); + const badP = new ModelElement( 'paragraph', null, new ModelText( 'bar' ) ); - dispatcher.on( 'insert:paragraph', insertElement( elementGenerator ) ); - dispatcher.on( 'insert:paragraph', ( evt, data, consumable ) => { - expect( consumable.test( data.item, 'insert' ) ).to.be.true; + model.change( writer => { + writer.insert( [ niceP, badP ], modelRootStart ); } ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); - - expect( viewToString( viewRoot ) ).to.equal( '
' ); - expect( dispatcher.fire.calledWith( 'insert:paragraph' ) ).to.be.true; + expect( viewToString( viewRoot ) ).to.equal( '
foo

bar

' ); } ); } ); - describe( 'setAttribute/removeAttribute', () => { + describe( 'setAttribute', () => { it( 'should convert attribute insert/change/remove on a model node', () => { const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) ); - const viewElement = new ViewContainerElement( 'p' ); - modelRoot.appendChildren( modelElement ); - dispatcher.on( 'insert:paragraph', insertElement( viewElement ) ); - dispatcher.on( 'insert:$text', insertText() ); - dispatcher.on( 'addAttribute:class', setAttribute() ); - dispatcher.on( 'changeAttribute:class', setAttribute() ); - dispatcher.on( 'removeAttribute:class', removeAttribute() ); - - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( modelElement, modelRootStart ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - modelElement.setAttribute( 'class', 'bar' ); - dispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'foo', 'bar' ); + model.change( writer => { + writer.setAttribute( 'class', 'bar', modelElement ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - modelElement.removeAttribute( 'class' ); - dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'bar', null ); + model.change( writer => { + writer.removeAttribute( 'class', modelElement ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); it( 'should convert insert/change/remove with attribute generating function as a parameter', () => { - const modelParagraph = new ModelElement( 'paragraph', { theme: 'nice' }, new ModelText( 'foobar' ) ); - const modelDiv = new ModelElement( 'div', { theme: 'nice' } ); - const themeConverter = ( value, key, data ) => { if ( data.item instanceof ModelElement && data.item.childCount > 0 ) { value += ' fix-content'; @@ -460,98 +178,69 @@ describe( 'model-to-view-converters', () => { return { key: 'class', value }; }; - modelRoot.appendChildren( [ modelParagraph, modelDiv ] ); - dispatcher.on( 'insert:paragraph', insertElement( new ViewContainerElement( 'p' ) ) ); dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) ); - dispatcher.on( 'insert:$text', insertText() ); - dispatcher.on( 'addAttribute:theme', setAttribute( themeConverter ) ); - dispatcher.on( 'changeAttribute:theme', setAttribute( themeConverter ) ); - dispatcher.on( 'removeAttribute:theme', removeAttribute( themeConverter ) ); + dispatcher.on( 'attribute:theme', changeAttribute( themeConverter ) ); + + const modelParagraph = new ModelElement( 'paragraph', { theme: 'nice' }, new ModelText( 'foobar' ) ); + const modelDiv = new ModelElement( 'div', { theme: 'nice' } ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( [ modelParagraph, modelDiv ], modelRootStart ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - modelParagraph.setAttribute( 'theme', 'awesome' ); - dispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelParagraph ), 'theme', 'nice', 'awesome' ); + model.change( writer => { + writer.setAttribute( 'theme', 'awesome', modelParagraph ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - modelParagraph.removeAttribute( 'theme' ); - dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelParagraph ), 'theme', 'awesome', null ); + model.change( writer => { + writer.removeAttribute( 'theme', modelParagraph ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); it( 'should be possible to override setAttribute', () => { const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) ); - const viewElement = new ViewContainerElement( 'p' ); - - modelRoot.appendChildren( modelElement ); - dispatcher.on( 'insert:paragraph', insertElement( viewElement ) ); - dispatcher.on( 'insert:$text', insertText() ); - dispatcher.on( 'addAttribute:class', setAttribute() ); - dispatcher.on( 'addAttribute:class', ( evt, data, consumable ) => { - consumable.consume( data.item, 'addAttribute:class' ); + + dispatcher.on( 'attribute:class', ( evt, data, consumable ) => { + consumable.consume( data.item, 'attribute:class' ); }, { priority: 'high' } ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( modelElement, modelRootStart ); + } ); // No attribute set. expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); - - it( 'should be possible to override removeAttribute', () => { - const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) ); - const viewElement = new ViewContainerElement( 'p' ); - - modelRoot.appendChildren( modelElement ); - dispatcher.on( 'insert:paragraph', insertElement( viewElement ) ); - dispatcher.on( 'insert:$text', insertText() ); - dispatcher.on( 'addAttribute:class', setAttribute() ); - dispatcher.on( 'removeAttribute:class', removeAttribute() ); - dispatcher.on( 'removeAttribute:class', ( evt, data, consumable ) => { - consumable.consume( data.item, 'removeAttribute:class' ); - }, { priority: 'high' } ); - - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); - - expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - - modelElement.removeAttribute( 'class' ); - dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'bar', null ); - - // Nothing changed. - expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - } ); } ); - describe( 'wrap/unwrap', () => { + describe( 'wrap', () => { it( 'should convert insert/change/remove of attribute in model into wrapping element in a view', () => { const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) ); - const viewP = new ViewContainerElement( 'p' ); const viewB = new ViewAttributeElement( 'b' ); - modelRoot.appendChildren( modelElement ); - dispatcher.on( 'insert:paragraph', insertElement( viewP ) ); - dispatcher.on( 'insert:$text', insertText() ); - dispatcher.on( 'addAttribute:bold', wrapItem( viewB ) ); - dispatcher.on( 'removeAttribute:bold', unwrapItem( viewB ) ); + dispatcher.on( 'attribute:bold', wrap( viewB ) ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( modelElement, modelRootStart ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - modelWriter.removeAttribute( 'bold', modelElement ); - - dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'bold', true, null ); + model.change( writer => { + writer.removeAttribute( 'bold', ModelRange.createIn( modelElement ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); it( 'should convert insert/remove of attribute in model with wrapping element generating function as a parameter', () => { const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { style: 'bold' } ) ); - const viewP = new ViewContainerElement( 'p' ); const elementGenerator = value => { if ( value == 'bold' ) { @@ -559,19 +248,17 @@ describe( 'model-to-view-converters', () => { } }; - modelRoot.appendChildren( modelElement ); - dispatcher.on( 'insert:paragraph', insertElement( viewP ) ); - dispatcher.on( 'insert:$text', insertText() ); - dispatcher.on( 'addAttribute:style', wrapItem( elementGenerator ) ); - dispatcher.on( 'removeAttribute:style', unwrapItem( elementGenerator ) ); + dispatcher.on( 'attribute:style', wrap( elementGenerator ) ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( modelElement, modelRootStart ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - modelWriter.removeAttribute( 'style', modelElement ); - - dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'style', 'bold', null ); + model.change( writer => { + writer.removeAttribute( 'style', ModelRange.createIn( modelElement ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); @@ -583,285 +270,167 @@ describe( 'model-to-view-converters', () => { new ModelText( 'x' ) ] ); - const viewP = new ViewContainerElement( 'p' ); - const elementGenerator = href => new ViewAttributeElement( 'a', { href } ); - modelRoot.appendChildren( modelElement ); - dispatcher.on( 'insert:paragraph', insertElement( viewP ) ); - dispatcher.on( 'insert:$text', insertText() ); - dispatcher.on( 'addAttribute:link', wrapItem( elementGenerator ) ); - dispatcher.on( 'changeAttribute:link', wrapItem( elementGenerator ) ); + dispatcher.on( 'attribute:link', wrap( elementGenerator ) ); - dispatcher.convertInsertion( - ModelRange.createIn( modelRoot ) - ); + model.change( writer => { + writer.insert( modelElement, modelRootStart ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

xfoox

' ); - modelWriter.setAttribute( 'link', 'http://foobar.com', modelElement ); - - dispatcher.convertAttribute( - 'changeAttribute', - ModelRange.createIn( modelElement ), - 'link', - 'http://foo.com', - 'http://foobar.com' - ); + // Set new attribute on old link but also on non-linked characters. + model.change( writer => { + writer.setAttribute( 'link', 'http://foobar.com', ModelRange.createIn( modelElement ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '' ); } ); it( 'should support unicode', () => { const modelElement = new ModelElement( 'paragraph', null, [ 'நி', new ModelText( 'லைக்', { bold: true } ), 'கு' ] ); - const viewP = new ViewContainerElement( 'p' ); const viewB = new ViewAttributeElement( 'b' ); - modelRoot.appendChildren( modelElement ); - dispatcher.on( 'insert:paragraph', insertElement( viewP ) ); - dispatcher.on( 'insert:$text', insertText() ); - dispatcher.on( 'addAttribute:bold', wrapItem( viewB ) ); - dispatcher.on( 'removeAttribute:bold', unwrapItem( viewB ) ); + dispatcher.on( 'attribute:bold', wrap( viewB ) ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( modelElement, modelRootStart ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

நிலைக்கு

' ); - modelWriter.removeAttribute( 'bold', modelElement ); - - dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'bold', true, null ); + model.change( writer => { + writer.removeAttribute( 'bold', ModelRange.createIn( modelElement ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

நிலைக்கு

' ); } ); it( 'should be possible to override wrap', () => { const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) ); - const viewP = new ViewContainerElement( 'p' ); const viewB = new ViewAttributeElement( 'b' ); - modelRoot.appendChildren( modelElement ); - dispatcher.on( 'insert:paragraph', insertElement( viewP ) ); - dispatcher.on( 'insert:$text', insertText() ); - dispatcher.on( 'addAttribute:bold', wrapItem( viewB ) ); - dispatcher.on( 'addAttribute:bold', ( evt, data, consumable ) => { - consumable.consume( data.item, 'addAttribute:bold' ); + dispatcher.on( 'attribute:bold', wrap( viewB ) ); + dispatcher.on( 'attribute:bold', ( evt, data, consumable ) => { + consumable.consume( data.item, 'attribute:bold' ); }, { priority: 'high' } ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + model.change( writer => { + writer.insert( modelElement, modelRootStart ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); - it( 'should be possible to override unwrap', () => { - const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) ); - const viewP = new ViewContainerElement( 'p' ); - const viewB = new ViewAttributeElement( 'b' ); - - modelRoot.appendChildren( modelElement ); - dispatcher.on( 'insert:paragraph', insertElement( viewP ) ); - dispatcher.on( 'insert:$text', insertText() ); - dispatcher.on( 'addAttribute:bold', wrapItem( viewB ) ); - dispatcher.on( 'removeAttribute:bold', unwrapItem( viewB ) ); - dispatcher.on( 'removeAttribute:bold', ( evt, data, consumable ) => { - consumable.consume( data.item, 'removeAttribute:bold' ); - }, { priority: 'high' } ); - - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); - - expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - - modelWriter.removeAttribute( 'bold', modelElement ); - - dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'bold', true, null ); - - // Nothing changed. - expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - } ); - it( 'should not convert and not consume if creator function returned null', () => { const elementGenerator = () => null; - const modelText = new ModelText( 'foo', { bold: true } ); - - modelRoot.appendChildren( [ modelText ] ); - sinon.spy( dispatcher, 'fire' ); - dispatcher.on( 'insert:$text', insertText() ); - dispatcher.on( 'addAttribute:bold', wrapItem( elementGenerator ) ); - dispatcher.on( 'removeAttribute:bold', unwrapItem( elementGenerator ) ); + const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { italic: true } ) ); - dispatcher.on( 'addAttribute:bold', ( evt, data, consumable ) => { - expect( consumable.test( data.item, 'addAttribute:bold' ) ).to.be.true; + dispatcher.on( 'attribute:italic', wrap( elementGenerator ) ); + dispatcher.on( 'attribute:italic', ( evt, data, consumable ) => { + expect( consumable.test( data.item, 'attribute:italic' ) ).to.be.true; } ); - dispatcher.on( 'removeAttribute:bold', ( evt, data, consumable ) => { - expect( consumable.test( data.item, 'removeAttribute:bold' ) ).to.be.true; - } ); - - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); - expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); - expect( dispatcher.fire.calledWith( 'addAttribute:bold:$text' ) ).to.be.true; + model.change( writer => { + writer.insert( modelElement, modelRootStart ); + } ); - modelText.removeAttribute( 'bold' ); - dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'bold', true, null ); - expect( dispatcher.fire.calledWith( 'removeAttribute:bold:$text' ) ).to.be.true; + expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); + expect( dispatcher.fire.calledWith( 'attribute:italic:$text' ) ).to.be.true; } ); } ); describe( 'insertUIElement/removeUIElement', () => { - let modelText, range, modelElement; + let modelText, modelElement, range; beforeEach( () => { modelText = new ModelText( 'foobar' ); modelElement = new ModelElement( 'paragraph', null, modelText ); - modelRoot.appendChildren( modelElement ); - - const viewText = new ViewText( 'foobar' ); - const viewElement = new ViewContainerElement( 'p', null, viewText ); - viewRoot.appendChildren( viewElement ); - - mapper.bindElements( modelElement, viewElement ); - - range = ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 3 ); - } ); - - it( 'should insert and remove ui element - element as a creator', () => { - const viewUi = new ViewUIElement( 'span', { 'class': 'marker' } ); - - dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) ); - dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) ); - - dispatcher.convertMarker( 'addMarker', 'marker', range ); - - expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - - dispatcher.convertMarker( 'removeMarker', 'marker', range ); - - expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - } ); - - it( 'should insert and remove ui element - function as a creator', () => { - const viewUi = data => new ViewUIElement( 'span', { 'class': data.markerName } ); - dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) ); - dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) ); - - dispatcher.convertMarker( 'addMarker', 'marker', range ); - - expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - - dispatcher.convertMarker( 'removeMarker', 'marker', range ); - - expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - } ); - - it( 'should not convert or consume if generator function returned null', () => { - const viewUi = () => null; - - sinon.spy( dispatcher, 'fire' ); - - dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) ); - dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) ); - - dispatcher.on( 'addMarker:marker', ( evt, data, consumable ) => { - expect( consumable.test( data.markerRange, 'addMarker:marker' ) ).to.be.true; - } ); - - dispatcher.on( 'removeMarker:marker', ( evt, data, consumable ) => { - expect( consumable.test( data.markerRange, 'removeMarker:marker' ) ).to.be.true; + model.change( writer => { + writer.insert( modelElement, modelRootStart ); } ); - - dispatcher.convertMarker( 'addMarker', 'marker', range ); - - expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - expect( dispatcher.fire.calledWith( 'addMarker:marker' ) ); - - dispatcher.convertMarker( 'removeMarker', 'marker', range ); - - expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - expect( dispatcher.fire.calledWith( 'removeMarker:marker' ) ); } ); - it( 'should be possible to overwrite', () => { - const viewUi = new ViewUIElement( 'span', { 'class': 'marker' } ); - - sinon.spy( dispatcher, 'fire' ); + describe( 'collapsed range', () => { + beforeEach( () => { + range = ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 3 ); + } ); - dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) ); - dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) ); + it( 'should insert and remove ui element - element as a creator', () => { + const viewUi = new ViewUIElement( 'span', { 'class': 'marker' } ); - dispatcher.on( 'addMarker:marker', ( evt, data, consumable ) => { - consumable.consume( data.markerRange, 'addMarker:marker' ); - }, { priority: 'high' } ); + dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) ); + dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) ); - dispatcher.on( 'removeMarker:marker', ( evt, data, consumable ) => { - consumable.consume( data.markerRange, 'removeMarker:marker' ); - }, { priority: 'high' } ); + model.change( () => { + model.markers.set( 'marker', range ); + } ); - dispatcher.convertMarker( 'addMarker', 'marker', range ); + expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - expect( dispatcher.fire.calledWith( 'addMarker:marker' ) ); + model.change( () => { + model.markers.remove( 'marker' ); + } ); - dispatcher.convertMarker( 'removeMarker', 'marker', range ); + expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); + } ); - expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - expect( dispatcher.fire.calledWith( 'removeMarker:marker' ) ); - } ); + it( 'should insert and remove ui element - function as a creator', () => { + const viewUi = new ViewUIElement( 'span', { 'class': 'marker' } ); - it( 'should not convert or consume if generator function returned null', () => { - const viewUi = () => null; + dispatcher.on( 'addMarker:marker', insertUIElement( () => viewUi ) ); + dispatcher.on( 'removeMarker:marker', removeUIElement( () => viewUi ) ); - sinon.spy( dispatcher, 'fire' ); + model.change( () => { + model.markers.set( 'marker', range ); + } ); - dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) ); - dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) ); + expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - dispatcher.on( 'addMarker:marker', ( evt, data, consumable ) => { - expect( consumable.test( data.markerRange, 'addMarker:marker' ) ).to.be.true; - } ); + model.change( () => { + model.markers.remove( 'marker' ); + } ); - dispatcher.on( 'removeMarker:marker', ( evt, data, consumable ) => { - expect( consumable.test( data.markerRange, 'removeMarker:marker' ) ).to.be.true; + expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); - dispatcher.convertMarker( 'addMarker', 'marker', range ); - - expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - expect( dispatcher.fire.calledWith( 'addMarker:marker' ) ); - - dispatcher.convertMarker( 'removeMarker', 'marker', range ); - - expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - expect( dispatcher.fire.calledWith( 'removeMarker:marker' ) ); - } ); + it( 'should not convert if consumable was consumed', () => { + const viewUi = new ViewUIElement( 'span', { 'class': 'marker' } ); - it( 'should be possible to overwrite', () => { - const viewUi = new ViewUIElement( 'span', { 'class': 'marker' } ); + sinon.spy( dispatcher, 'fire' ); - sinon.spy( dispatcher, 'fire' ); + dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) ); + dispatcher.on( 'addMarker:marker', ( evt, data, consumable ) => { + consumable.consume( data.markerRange, 'addMarker:marker' ); + }, { priority: 'high' } ); - dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) ); - dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) ); + dispatcher.convertMarkerAdd( 'marker', range ); - dispatcher.on( 'addMarker:marker', ( evt, data, consumable ) => { - consumable.consume( data.markerRange, 'addMarker:marker' ); - }, { priority: 'high' } ); + expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); + expect( dispatcher.fire.calledWith( 'addMarker:marker' ) ); + } ); - dispatcher.on( 'removeMarker:marker', ( evt, data, consumable ) => { - consumable.consume( data.markerRange, 'removeMarker:marker' ); - }, { priority: 'high' } ); + it( 'should not convert if creator returned null', () => { + dispatcher.on( 'addMarker:marker', insertUIElement( () => null ) ); + dispatcher.on( 'removeMarker:marker', removeUIElement( () => null ) ); - dispatcher.convertMarker( 'addMarker', 'marker', range ); + model.change( () => { + model.markers.set( 'marker', range ); + } ); - expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - expect( dispatcher.fire.calledWith( 'addMarker:marker' ) ); + expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - dispatcher.convertMarker( 'removeMarker', 'marker', range ); + model.change( () => { + model.markers.remove( 'marker' ); + } ); - expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - expect( dispatcher.fire.calledWith( 'removeMarker:marker' ) ); + expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); + } ); } ); describe( 'non-collapsed range', () => { @@ -875,12 +444,16 @@ describe( 'model-to-view-converters', () => { dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) ); dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) ); - dispatcher.convertMarker( 'addMarker', 'marker', range ); + model.change( () => { + model.markers.set( 'marker', range ); + } ); expect( viewToString( viewRoot ) ) .to.equal( '

foobar

' ); - dispatcher.convertMarker( 'removeMarker', 'marker', range ); + model.change( () => { + model.markers.remove( 'marker' ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); @@ -891,12 +464,16 @@ describe( 'model-to-view-converters', () => { dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) ); dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) ); - dispatcher.convertMarker( 'addMarker', 'marker', range ); + model.change( () => { + model.markers.set( 'marker', range ); + } ); expect( viewToString( viewRoot ) ) .to.equal( '

foobar

' ); - dispatcher.convertMarker( 'removeMarker', 'marker', range ); + model.change( () => { + model.markers.remove( 'marker' ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); @@ -913,306 +490,436 @@ describe( 'model-to-view-converters', () => { dispatcher.on( 'addMarker:marker', insertUIElement( creator ) ); dispatcher.on( 'removeMarker:marker', removeUIElement( creator ) ); - dispatcher.convertMarker( 'addMarker', 'marker', range ); + model.change( () => { + model.markers.set( 'marker', range ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - dispatcher.convertMarker( 'removeMarker', 'marker', range ); + model.change( () => { + model.markers.remove( 'marker' ); + } ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); - it( 'should be possible to overwrite', () => { + it( 'should not convert if consumable was consumed', () => { const viewUi = new ViewUIElement( 'span', { 'class': 'marker' } ); sinon.spy( dispatcher, 'fire' ); dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) ); - dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) ); - dispatcher.on( 'addMarker:marker', ( evt, data, consumable ) => { consumable.consume( data.item, 'addMarker:marker' ); }, { priority: 'high' } ); - dispatcher.on( 'removeMarker:marker', ( evt, data, consumable ) => { - consumable.consume( data.item, 'removeMarker:marker' ); - }, { priority: 'high' } ); - - dispatcher.convertMarker( 'addMarker', 'marker', range ); + dispatcher.convertMarkerAdd( 'marker', range ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); expect( dispatcher.fire.calledWith( 'addMarker:marker' ) ); - - dispatcher.convertMarker( 'removeMarker', 'marker', range ); - - expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); - expect( dispatcher.fire.calledWith( 'removeMarker:marker' ) ); } ); } ); } ); + // Remove converter is by default already added in `EditingController` instance. describe( 'remove', () => { it( 'should remove items from view accordingly to changes in model #1', () => { - const modelDiv = new ModelElement( 'div', null, [ - new ModelText( 'foo' ), - new ModelElement( 'image' ), - new ModelText( 'bar' ) - ] ); - - modelRoot.appendChildren( modelDiv ); - dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) ); - dispatcher.on( 'insert:image', insertElement( new ViewContainerElement( 'img' ) ) ); - dispatcher.on( 'insert:$text', insertText() ); - dispatcher.on( 'remove', remove() ); - - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) ); - const removedNodes = modelDiv.removeChildren( 0, 2 ); - modelDoc.graveyard.insertChildren( 0, removedNodes ); + model.change( writer => { + writer.insert( modelElement, modelRootStart ); + } ); - dispatcher.convertRemove( - ModelPosition.createFromParentAndOffset( modelDiv, 0 ), - ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 4 ) - ); + model.change( writer => { + writer.remove( ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) ); + } ); - expect( viewToString( viewRoot ) ).to.equal( '
bar
' ); + expect( viewToString( viewRoot ) ).to.equal( '

foar

' ); } ); - it( 'should not execute if value was already consumed', () => { - const modelDiv = new ModelElement( 'div', null, new ModelText( 'foo' ) ); - - modelRoot.appendChildren( modelDiv ); - dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) ); - dispatcher.on( 'insert:$text', insertText() ); - dispatcher.on( 'remove', remove() ); - dispatcher.on( 'remove', ( evt, data, consumable ) => { - consumable.consume( data.item, 'remove' ); - }, { priority: 'high' } ); - - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); + it( 'should be possible to overwrite', () => { + dispatcher.on( 'remove', evt => evt.stop(), { priority: 'high' } ); - expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); + const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) ); - const removedNodes = modelDiv.removeChildren( 0, 1 ); - modelDoc.graveyard.insertChildren( 0, removedNodes ); + model.change( writer => { + writer.insert( modelElement, modelRootStart ); + } ); - dispatcher.convertRemove( - ModelPosition.createFromParentAndOffset( modelDiv, 0 ), - ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 3 ) - ); + model.change( writer => { + writer.remove( ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) ); + } ); - expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); + expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); it( 'should support unicode', () => { - const modelDiv = new ModelElement( 'div', null, 'நிலைக்கு' ); - - modelRoot.appendChildren( modelDiv ); - dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) ); - dispatcher.on( 'insert:$text', insertText() ); - dispatcher.on( 'remove', remove() ); + const modelElement = new ModelElement( 'paragraph', null, 'நிலைக்கு' ); - dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); - - modelWriter.remove( ModelRange.createFromParentsAndOffsets( modelDiv, 0, modelDiv, 6 ) ); + model.change( writer => { + writer.insert( modelElement, modelRootStart ); + } ); - dispatcher.convertRemove( - ModelPosition.createFromParentAndOffset( modelDiv, 0 ), - ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 6 ) - ); + model.change( writer => { + writer.remove( ModelRange.createFromParentsAndOffsets( modelElement, 0, modelElement, 6 ) ); + } ); - expect( viewToString( viewRoot ) ).to.equal( '
கு
' ); + expect( viewToString( viewRoot ) ).to.equal( '

கு

' ); } ); it( 'should not remove view ui elements that are placed next to removed content', () => { - modelRoot.appendChildren( new ModelText( 'foobar' ) ); + modelRoot.appendChildren( new ModelText( 'fozbar' ) ); viewRoot.appendChildren( [ - new ViewText( 'foo' ), + new ViewText( 'foz' ), new ViewUIElement( 'span' ), new ViewText( 'bar' ) ] ); - dispatcher.on( 'remove', remove() ); - // Remove 'b'. - modelWriter.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 3, modelRoot, 4 ) ); + model.change( writer => { + writer.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 3, modelRoot, 4 ) ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( '
fozar
' ); - dispatcher.convertRemove( - ModelPosition.createFromParentAndOffset( modelRoot, 3 ), - ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 1 ) - ); + // Remove 'z'. + model.change( writer => { + writer.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 2, modelRoot, 3 ) ); + } ); - expect( viewToString( viewRoot ) ).to.equal( '
fooar
' ); + expect( viewToString( viewRoot ) ).to.equal( '
foar
' ); } ); it( 'should remove correct amount of text when it is split by view ui element', () => { - modelRoot.appendChildren( new ModelText( 'foobar' ) ); + modelRoot.appendChildren( new ModelText( 'fozbar' ) ); viewRoot.appendChildren( [ - new ViewText( 'foo' ), + new ViewText( 'foz' ), new ViewUIElement( 'span' ), new ViewText( 'bar' ) ] ); - dispatcher.on( 'remove', remove() ); - - // Remove 'ob'. - modelWriter.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 2, modelRoot, 4 ) ); - - dispatcher.convertRemove( - ModelPosition.createFromParentAndOffset( modelRoot, 2 ), - ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 2 ) - ); + // Remove 'zb'. + model.change( writer => { + writer.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 2, modelRoot, 4 ) ); + } ); expect( viewToString( viewRoot ) ).to.equal( '
foar
' ); } ); - it( 'should not unbind element that has not been moved to graveyard', () => { + it( 'should unbind elements', () => { const modelElement = new ModelElement( 'paragraph' ); - const viewElement = new ViewContainerElement( 'p' ); - modelRoot.appendChildren( [ modelElement, new ModelText( 'b' ) ] ); - viewRoot.appendChildren( [ viewElement, new ViewText( 'b' ) ] ); + model.change( writer => { + writer.insert( modelElement, modelRootStart ); + } ); - mapper.bindElements( modelElement, viewElement ); + const viewElement = controller.mapper.toViewElement( modelElement ); + expect( viewElement ).not.to.be.undefined; + expect( controller.mapper.toModelElement( viewElement ) ).to.equal( modelElement ); - dispatcher.on( 'remove', remove() ); + model.change( writer => { + writer.remove( modelElement ); + } ); - // Move after "b". Can be e.g. a part of an unwrap delta (move + remove). - modelWriter.move( - ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ), - ModelPosition.createAt( modelRoot, 'end' ) - ); + expect( controller.mapper.toViewElement( modelElement ) ).to.be.undefined; + expect( controller.mapper.toModelElement( viewElement ) ).to.be.undefined; + } ); - dispatcher.convertRemove( - ModelPosition.createFromParentAndOffset( modelRoot, 0 ), - ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 ) - ); + it( 'should not break when remove() is used as part of unwrapping', () => { + const modelP = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) ); + const modelWidget = new ModelElement( 'widget', null, modelP ); + + dispatcher.on( 'insert:widget', insertElement( () => new ViewContainerElement( 'widget' ) ) ); + + model.change( writer => { + writer.insert( modelWidget, modelRootStart ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( '

foo

' ); - expect( viewToString( viewRoot ) ).to.equal( '
b
' ); + const viewP = controller.mapper.toViewElement( modelP ); - expect( mapper.toModelElement( viewElement ) ).to.equal( modelElement ); - expect( mapper.toViewElement( modelElement ) ).to.equal( viewElement ); + expect( viewP ).not.to.be.undefined; + + model.change( writer => { + writer.unwrap( modelWidget ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( '

foo

' ); + // `modelP` is now bound with newly created view element. + expect( controller.mapper.toViewElement( modelP ) ).not.to.equal( viewP ); + // `viewP` is no longer bound with model element. + expect( controller.mapper.toModelElement( viewP ) ).to.be.undefined; + // View element from view root is bound to `modelP`. + expect( controller.mapper.toModelElement( viewRoot.getChild( 0 ) ) ).to.equal( modelP ); } ); - it( 'should unbind elements if model element was moved to graveyard', () => { - const modelElement = new ModelElement( 'paragraph' ); - const viewElement = new ViewContainerElement( 'p' ); + it( 'should work correctly if container element after ui element is removed', () => { + // Prepare a model and view structure. + // This is done outside of conversion to put view ui elements inside easily. + const modelP1 = new ModelElement( 'paragraph' ); + const modelP2 = new ModelElement( 'paragraph' ); - modelRoot.appendChildren( [ modelElement, new ModelText( 'b' ) ] ); - viewRoot.appendChildren( [ viewElement, new ViewText( 'b' ) ] ); + const viewP1 = new ViewContainerElement( 'p' ); + const viewUi1 = new ViewUIElement( 'span' ); + const viewUi2 = new ViewUIElement( 'span' ); + const viewP2 = new ViewContainerElement( 'p' ); - mapper.bindElements( modelElement, viewElement ); + modelRoot.appendChildren( [ modelP1, modelP2 ] ); + viewRoot.appendChildren( [ viewP1, viewUi1, viewUi2, viewP2 ] ); - dispatcher.on( 'remove', remove() ); + controller.mapper.bindElements( modelP1, viewP1 ); + controller.mapper.bindElements( modelP2, viewP2 ); - // Remove . - modelWriter.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ) ); + // Remove second paragraph element. + model.change( writer => { + writer.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 ) ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( '

' ); + } ); + + it( 'should work correctly if container element after text node is removed', () => { + const modelText = new ModelText( 'foo' ); + const modelP = new ModelElement( 'paragraph' ); - dispatcher.convertRemove( - ModelPosition.createFromParentAndOffset( modelRoot, 0 ), - ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 1 ) - ); + model.change( writer => { + writer.insert( [ modelText, modelP ], modelRootStart ); + } ); - expect( viewToString( viewRoot ) ).to.equal( '
b
' ); + model.change( writer => { + writer.remove( modelP ); + } ); - expect( mapper.toModelElement( viewElement ) ).to.be.undefined; - expect( mapper.toViewElement( modelElement ) ).to.be.undefined; + expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); } ); + } ); - // TODO move to conversion/integration.js one day. - it( 'should not break when remove() is used as part of unwrapping', () => { - // The whole process looks like this: - // => => => - // The is duplicated for a while in the view. + describe( 'highlight', () => { + describe( 'on text', () => { + const highlightDescriptor = { + class: 'highlight-class', + priority: 7, + attributes: { title: 'title' } + }; - const modelAElement = new ModelElement( 'a' ); - const modelWElement = new ModelElement( 'w' ); - const viewAElement = new ViewContainerElement( 'a' ); - const viewA2Element = new ViewContainerElement( 'a2' ); - const viewWElement = new ViewContainerElement( 'w' ); + let markerRange; - modelRoot.appendChildren( modelWElement ); - viewRoot.appendChildren( viewWElement ); + beforeEach( () => { + const modelElement1 = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) ); + const modelElement2 = new ModelElement( 'paragraph', null, new ModelText( 'bar' ) ); - modelWElement.appendChildren( modelAElement ); - viewWElement.appendChildren( viewAElement ); + model.change( writer => { + writer.insert( [ modelElement1, modelElement2 ], modelRootStart ); + } ); - mapper.bindElements( modelWElement, viewWElement ); - mapper.bindElements( modelAElement, viewAElement ); + markerRange = ModelRange.createIn( modelRoot ); + } ); - dispatcher.on( 'remove', remove() ); - dispatcher.on( 'insert', insertElement( () => viewA2Element ) ); + it( 'should wrap and unwrap text nodes', () => { + dispatcher.on( 'addMarker:marker', highlightText( highlightDescriptor ) ); + dispatcher.on( 'addMarker:marker', highlightElement( highlightDescriptor ) ); + dispatcher.on( 'removeMarker:marker', removeHighlight( highlightDescriptor ) ); - modelDoc.on( 'change', ( evt, type, changes ) => { - dispatcher.convertChange( type, changes ); + model.change( () => { + model.markers.set( 'marker', markerRange ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( + '
' + + '

' + + 'foo' + + '

' + + '

' + + 'bar' + + '

' + + '
' + ); + + model.change( () => { + model.markers.remove( 'marker' ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( '

foo

bar

' ); } ); - modelWriter.unwrap( modelWElement ); + it( 'should be possible to overwrite', () => { + dispatcher.on( 'addMarker:marker', highlightText( highlightDescriptor ) ); + dispatcher.on( 'addMarker:marker', highlightElement( highlightDescriptor ) ); + dispatcher.on( 'removeMarker:marker', removeHighlight( highlightDescriptor ) ); - expect( viewToString( viewRoot ) ).to.equal( '
' ); + const newDescriptor = { class: 'override-class' }; - expect( mapper.toModelElement( viewA2Element ) ).to.equal( modelAElement ); - expect( mapper.toViewElement( modelAElement ) ).to.equal( viewA2Element ); + dispatcher.on( 'addMarker:marker', highlightText( newDescriptor ), { priority: 'high' } ); + dispatcher.on( 'addMarker:marker', highlightElement( newDescriptor ), { priority: 'high' } ); + dispatcher.on( 'removeMarker:marker', removeHighlight( newDescriptor ), { priority: 'high' } ); - // This is a bit unfortunate, but we think we can live with this. - // The viewAElement is not in the tree and there's a high chance that all reference to it are gone. - expect( mapper.toModelElement( viewAElement ) ).to.equal( modelAElement ); + model.change( () => { + model.markers.set( 'marker', markerRange ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( + '
' + + '

' + + 'foo' + + '

' + + '

' + + 'bar' + + '

' + + '
' + ); + + model.change( () => { + model.markers.remove( 'marker' ); + } ); - expect( mapper.toModelElement( viewWElement ) ).to.be.undefined; - expect( mapper.toViewElement( modelWElement ) ).to.be.undefined; + expect( viewToString( viewRoot ) ).to.equal( '

foo

bar

' ); + } ); + + it( 'should do nothing if descriptor is not provided or generating function returns null', () => { + dispatcher.on( 'addMarker:marker', highlightText( () => null ), { priority: 'high' } ); + dispatcher.on( 'addMarker:marker', highlightElement( () => null ), { priority: 'high' } ); + dispatcher.on( 'removeMarker:marker', removeHighlight( () => null ), { priority: 'high' } ); + + model.change( () => { + model.markers.set( 'marker', markerRange ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( '

foo

bar

' ); + + model.change( () => { + model.markers.remove( 'marker' ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( '

foo

bar

' ); + } ); } ); - it( 'should work correctly if container element after ui element is removed', () => { - const modelP1 = new ModelElement( 'paragraph' ); - const modelP2 = new ModelElement( 'paragraph' ); + describe( 'on element', () => { + const highlightDescriptor = { + class: 'highlight-class', + priority: 7, + attributes: { title: 'title' }, + id: 'customId' + }; - const viewP1 = new ViewContainerElement( 'p' ); - const viewUi1 = new ViewUIElement( 'span' ); - const viewUi2 = new ViewUIElement( 'span' ); - const viewP2 = new ViewContainerElement( 'p' ); + let markerRange; - modelRoot.appendChildren( [ modelP1, modelP2 ] ); - viewRoot.appendChildren( [ viewP1, viewUi1, viewUi2, viewP2 ] ); + beforeEach( () => { + // Provide converter for div element. View div element will have custom highlight handling. + dispatcher.on( 'insert:div', insertElement( () => { + const viewContainer = new ViewContainerElement( 'div' ); - mapper.bindElements( modelP1, viewP1 ); - mapper.bindElements( modelP2, viewP2 ); + viewContainer.setCustomProperty( 'addHighlight', ( element, descriptor ) => { + element.addClass( descriptor.class ); + } ); - dispatcher.on( 'remove', remove() ); + viewContainer.setCustomProperty( 'removeHighlight', element => { + element.setAttribute( 'class', '' ); + } ); - modelWriter.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 ) ); + return viewContainer; + } ) ); - dispatcher.convertRemove( - ModelPosition.createFromParentAndOffset( modelRoot, 1 ), - ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 1 ) - ); + const modelElement = new ModelElement( 'div', null, new ModelText( 'foo' ) ); - expect( viewToString( viewRoot ) ).to.equal( '

' ); - } ); + model.change( writer => { + writer.insert( modelElement, modelRootStart ); + } ); - it( 'should work correctly if container element after text node is removed', () => { - const modelText = new ModelText( 'foo' ); - const modelP = new ModelElement( 'paragraph' ); + markerRange = ModelRange.createOn( modelElement ); + + dispatcher.on( 'addMarker:marker', highlightText( highlightDescriptor ) ); + dispatcher.on( 'addMarker:marker', highlightElement( highlightDescriptor ) ); + dispatcher.on( 'removeMarker:marker', removeHighlight( highlightDescriptor ) ); + } ); + + it( 'should use addHighlight and removeHighlight on elements and not convert children nodes', () => { + model.change( () => { + model.markers.set( 'marker', markerRange ); + } ); - const viewText = new ViewText( 'foo' ); - const viewP = new ViewContainerElement( 'p' ); + expect( viewToString( viewRoot ) ).to.equal( + '
' + + '
' + + 'foo' + + '
' + + '
' + ); - modelRoot.appendChildren( [ modelText, modelP ] ); - viewRoot.appendChildren( [ viewText, viewP ] ); + model.change( () => { + model.markers.remove( 'marker' ); + } ); - mapper.bindElements( modelP, viewP ); + expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); + } ); - dispatcher.on( 'remove', remove() ); + it( 'should be possible to override', () => { + const newDescriptor = { class: 'override-class' }; - modelWriter.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 3, modelRoot, 4 ) ); + dispatcher.on( 'addMarker:marker', highlightText( newDescriptor ), { priority: 'high' } ); + dispatcher.on( 'addMarker:marker', highlightElement( newDescriptor ), { priority: 'high' } ); + dispatcher.on( 'removeMarker:marker', removeHighlight( newDescriptor ), { priority: 'high' } ); - dispatcher.convertRemove( - ModelPosition.createFromParentAndOffset( modelRoot, 3 ), - ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 1 ) - ); + model.change( () => { + model.markers.set( 'marker', markerRange ); + } ); - expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); + expect( viewToString( viewRoot ) ).to.equal( + '
' + + '
' + + 'foo' + + '
' + + '
' + ); + + model.change( () => { + model.markers.remove( 'marker' ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); + } ); + + it( 'should use default priority and id if not provided', () => { + const viewDiv = viewRoot.getChild( 0 ); + + dispatcher.on( 'addMarker:marker2', highlightText( () => null ) ); + dispatcher.on( 'addMarker:marker2', highlightElement( () => null ) ); + dispatcher.on( 'removeMarker:marker2', removeHighlight( () => null ) ); + + viewDiv.setCustomProperty( 'addHighlight', ( element, descriptor ) => { + expect( descriptor.priority ).to.equal( 10 ); + expect( descriptor.id ).to.equal( 'marker:foo-bar-baz' ); + } ); + + viewDiv.setCustomProperty( 'removeHighlight', ( element, id ) => { + expect( id ).to.equal( 'marker:foo-bar-baz' ); + } ); + + model.change( () => { + model.markers.set( 'marker2', markerRange ); + } ); + } ); + + it( 'should do nothing if descriptor is not provided', () => { + dispatcher.on( 'addMarker:marker2', highlightText( () => null ) ); + dispatcher.on( 'addMarker:marker2', highlightElement( () => null ) ); + dispatcher.on( 'removeMarker:marker2', removeHighlight( () => null ) ); + + model.change( () => { + model.markers.set( 'marker2', markerRange ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); + + model.change( () => { + model.markers.remove( 'marker2' ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); + } ); } ); } ); diff --git a/tests/conversion/modelconversiondispatcher.js b/tests/conversion/modelconversiondispatcher.js index b7a059eba..e8eb9a90e 100644 --- a/tests/conversion/modelconversiondispatcher.js +++ b/tests/conversion/modelconversiondispatcher.js @@ -9,18 +9,11 @@ import ModelText from '../../src/model/text'; import ModelElement from '../../src/model/element'; import ModelRange from '../../src/model/range'; import ModelPosition from '../../src/model/position'; -import ModelWriter from '../../src/model/writer'; -import Batch from '../../src/model/batch'; -import RemoveOperation from '../../src/model/operation/removeoperation'; -import NoOperation from '../../src/model/operation/nooperation'; -import RenameOperation from '../../src/model/operation/renameoperation'; -import AttributeOperation from '../../src/model/operation/attributeoperation'; -import { wrapInDelta } from '../../tests/model/_utils/utils'; import ViewContainerElement from '../../src/view/containerelement'; describe( 'ModelConversionDispatcher', () => { - let dispatcher, doc, root, gyPos, model, writer; + let dispatcher, doc, root, differStub, model; beforeEach( () => { model = new Model(); @@ -28,10 +21,11 @@ describe( 'ModelConversionDispatcher', () => { dispatcher = new ModelConversionDispatcher( model ); root = doc.createRoot(); - gyPos = new ModelPosition( doc.graveyard, [ 0 ] ); - - // As an util for modifying model tree. - writer = new ModelWriter( model, new Batch() ); + differStub = { + getMarkersToRemove: () => [], + getChanges: () => [], + getMarkersToAdd: () => [] + }; } ); describe( 'constructor()', () => { @@ -43,224 +37,102 @@ describe( 'ModelConversionDispatcher', () => { } ); } ); - describe( 'convertChange', () => { - // We will do integration tests here. Unit tests will be done for methods that are used - // by `convertChange` internally. This way we will have two kinds of tests. - - let image, imagePos; - - beforeEach( () => { - image = new ModelElement( 'image' ); - root.appendChildren( [ image, new ModelText( 'foobar' ) ] ); - - imagePos = ModelPosition.createBefore( image ); - - dispatcher.listenTo( doc, 'change', ( evt, type, changes ) => { - dispatcher.convertChange( type, changes ); - } ); - } ); - - it( 'should fire insert and addAttribute callbacks for insertion changes', () => { - const cbInsertText = sinon.spy(); - const cbInsertImage = sinon.spy(); - const cbAddAttribute = sinon.spy(); - - dispatcher.on( 'insert:$text', cbInsertText ); - dispatcher.on( 'insert:image', cbInsertImage ); - dispatcher.on( 'addAttribute:key:$text', cbAddAttribute ); - - model.change( writer => { - writer.insertText( 'foo', { key: 'value' }, root ); - } ); - - expect( cbInsertText.called ).to.be.true; - expect( cbAddAttribute.called ).to.be.true; - expect( cbInsertImage.called ).to.be.false; - } ); - - it( 'should fire insert and addAttribute callbacks for reinsertion changes', () => { - image.setAttribute( 'key', 'value' ); - - // We will just create reinsert operation by reverting remove operation - // because creating reinsert change is tricky and not available through batch API. - const removeOperation = new RemoveOperation( imagePos, 1, gyPos, 0 ); + describe( 'convertChanges', () => { + it( 'should call convertInsert for insert change', () => { + sinon.stub( dispatcher, 'convertInsert' ); - // Let's apply remove operation so reinsert operation won't break. - model.applyOperation( wrapInDelta( removeOperation ) ); - - const cbInsertText = sinon.spy(); - const cbInsertImage = sinon.spy(); - const cbAddAttribute = sinon.spy(); - - dispatcher.on( 'insert:$text', cbInsertText ); - dispatcher.on( 'insert:image', cbInsertImage ); - dispatcher.on( 'addAttribute:key:image', cbAddAttribute ); - - model.applyOperation( wrapInDelta( removeOperation.getReversed() ) ); - - expect( cbInsertImage.called ).to.be.true; - expect( cbAddAttribute.called ).to.be.true; - expect( cbInsertText.called ).to.be.false; - } ); - - it( 'should fire remove callback for remove changes', () => { - const cbRemove = sinon.spy(); - - dispatcher.on( 'remove', cbRemove ); - - model.change( writer => { - writer.remove( image ); - } ); - - expect( cbRemove.called ).to.be.true; - } ); - - it( 'should fire addAttribute callbacks for add attribute change', () => { - const cbAddText = sinon.spy(); - const cbAddImage = sinon.spy(); - - dispatcher.on( 'addAttribute:key:$text', cbAddText ); - dispatcher.on( 'addAttribute:key:image', cbAddImage ); - - model.change( writer => { - writer.setAttribute( 'key', 'value', image ); - } ); + const position = new ModelPosition( root, [ 0 ] ); + const range = ModelRange.createFromPositionAndShift( position, 1 ); - // Callback for adding attribute on text not called. - expect( cbAddText.called ).to.be.false; - expect( cbAddImage.calledOnce ).to.be.true; + differStub.getChanges = () => [ { type: 'insert', position, length: 1 } ]; - model.change( writer => { - writer.setAttribute( 'key', 'value', ModelRange.createFromParentsAndOffsets( root, 3, root, 4 ) ); - } ); + dispatcher.convertChanges( differStub ); - expect( cbAddText.calledOnce ).to.be.true; - // Callback for adding attribute on image not called this time. - expect( cbAddImage.calledOnce ).to.be.true; + expect( dispatcher.convertInsert.calledOnce ).to.be.true; + expect( dispatcher.convertInsert.firstCall.args[ 0 ].isEqual( range ) ).to.be.true; } ); - it( 'should fire changeAttribute callbacks for change attribute change', () => { - const cbChangeText = sinon.spy(); - const cbChangeImage = sinon.spy(); + it( 'should call convertRemove for remove change', () => { + sinon.stub( dispatcher, 'convertRemove' ); - dispatcher.on( 'changeAttribute:key:$text', cbChangeText ); - dispatcher.on( 'changeAttribute:key:image', cbChangeImage ); + const position = new ModelPosition( root, [ 0 ] ); - model.change( writer => { - writer.setAttribute( 'key', 'value', image ); - writer.setAttribute( 'key', 'newValue', image ); - } ); + differStub.getChanges = () => [ { type: 'remove', position, length: 2, name: '$text' } ]; - // Callback for adding attribute on text not called. - expect( cbChangeText.called ).to.be.false; - expect( cbChangeImage.calledOnce ).to.be.true; + dispatcher.convertChanges( differStub ); - const range = ModelRange.createFromParentsAndOffsets( root, 3, root, 4 ); - model.change( writer => { - writer.setAttribute( 'key', 'value', range ); - writer.setAttribute( 'key', 'newValue', range ); - } ); - - expect( cbChangeText.calledOnce ).to.be.true; - // Callback for adding attribute on image not called this time. - expect( cbChangeImage.calledOnce ).to.be.true; + expect( dispatcher.convertRemove.calledWith( position, 2, '$text' ) ).to.be.true; } ); - it( 'should fire removeAttribute callbacks for remove attribute change', () => { - const cbRemoveText = sinon.spy(); - const cbRemoveImage = sinon.spy(); + it( 'should call convertAttribute for attribute change', () => { + sinon.stub( dispatcher, 'convertAttribute' ); - dispatcher.on( 'removeAttribute:key:$text', cbRemoveText ); - dispatcher.on( 'removeAttribute:key:image', cbRemoveImage ); + const position = new ModelPosition( root, [ 0 ] ); + const range = ModelRange.createFromPositionAndShift( position, 1 ); - writer.setAttribute( 'key', 'value', image ); - writer.removeAttribute( 'key', image ); + differStub.getChanges = () => [ + { type: 'attribute', position, range, attributeKey: 'key', attributeOldValue: null, attributeNewValue: 'foo' } + ]; - // Callback for adding attribute on text not called. - expect( cbRemoveText.called ).to.be.false; - expect( cbRemoveImage.calledOnce ).to.be.true; + dispatcher.convertChanges( differStub ); - const range = ModelRange.createFromParentsAndOffsets( root, 3, root, 4 ); - writer.setAttribute( 'key', 'value', range ); - writer.removeAttribute( 'key', range ); - - expect( cbRemoveText.calledOnce ).to.be.true; - // Callback for adding attribute on image not called this time. - expect( cbRemoveImage.calledOnce ).to.be.true; + expect( dispatcher.convertAttribute.calledWith( range, 'key', null, 'foo' ) ).to.be.true; } ); - it( 'should not fire any event if not recognized event type was passed', () => { - sinon.spy( dispatcher, 'fire' ); - - dispatcher.convertChange( 'unknown', { foo: 'bar' } ); - - expect( dispatcher.fire.called ).to.be.false; - } ); - - it( 'should not fire any event if changed range in graveyard root and change type is different than remove', () => { - sinon.spy( dispatcher, 'fire' ); - - const gyNode = new ModelElement( 'image' ); - doc.graveyard.appendChildren( gyNode ); - - writer.setAttribute( 'key', 'value', gyNode ); + it( 'should handle multiple changes', () => { + sinon.stub( dispatcher, 'convertInsert' ); + sinon.stub( dispatcher, 'convertRemove' ); + sinon.stub( dispatcher, 'convertAttribute' ); - expect( dispatcher.fire.called ).to.be.false; - } ); + const position = new ModelPosition( root, [ 0 ] ); + const range = ModelRange.createFromPositionAndShift( position, 1 ); - it( 'should not fire any event if remove operation moves nodes between graveyard holders', () => { - // This may happen during OT. - sinon.spy( dispatcher, 'fire' ); + differStub.getChanges = () => [ + { type: 'insert', position, length: 1 }, + { type: 'attribute', position, range, attributeKey: 'key', attributeOldValue: null, attributeNewValue: 'foo' }, + { type: 'remove', position, length: 1, name: 'paragraph' }, + { type: 'insert', position, length: 3 }, + ]; - const gyNode = new ModelElement( 'image' ); - doc.graveyard.appendChildren( gyNode ); + dispatcher.convertChanges( differStub ); - writer.remove( gyNode ); - - expect( dispatcher.fire.called ).to.be.false; + expect( dispatcher.convertInsert.calledTwice ).to.be.true; + expect( dispatcher.convertRemove.calledOnce ).to.be.true; + expect( dispatcher.convertAttribute.calledOnce ).to.be.true; } ); - it( 'should not fire any event if element in graveyard was removed', () => { - // This may happen during OT. - sinon.spy( dispatcher, 'fire' ); + it( 'should call convertMarkerAdd when markers are added', () => { + sinon.stub( dispatcher, 'convertMarkerAdd' ); - const gyNode = new ModelElement( 'image' ); - doc.graveyard.appendChildren( gyNode ); + const fooRange = ModelRange.createFromParentsAndOffsets( root, 0, root, 1 ); + const barRange = ModelRange.createFromParentsAndOffsets( root, 3, root, 6 ); - writer.rename( gyNode, 'p' ); + differStub.getMarkersToAdd = () => [ + { name: 'foo', range: fooRange }, + { name: 'bar', range: barRange } + ]; - expect( dispatcher.fire.called ).to.be.false; - } ); + dispatcher.convertChanges( differStub ); - it( 'should not fire any event after NoOperation is applied', () => { - sinon.spy( dispatcher, 'fire' ); - - model.applyOperation( wrapInDelta( new NoOperation( 0 ) ) ); - - expect( dispatcher.fire.called ).to.be.false; + expect( dispatcher.convertMarkerAdd.calledWith( 'foo', fooRange ) ); + expect( dispatcher.convertMarkerAdd.calledWith( 'bar', barRange ) ); } ); - it( 'should not fire any event after RenameOperation with same old and new value is applied', () => { - sinon.spy( dispatcher, 'fire' ); - - root.removeChildren( 0, root.childCount ); - root.appendChildren( [ new ModelElement( 'paragraph' ) ] ); - - model.applyOperation( wrapInDelta( new RenameOperation( new ModelPosition( root, [ 0 ] ), 'paragraph', 'paragraph', 0 ) ) ); - - expect( dispatcher.fire.called ).to.be.false; - } ); + it( 'should call convertMarkerRemove when markers are removed', () => { + sinon.stub( dispatcher, 'convertMarkerRemove' ); - it( 'should not fire any event after AttributeOperation with same old an new value is applied', () => { - sinon.spy( dispatcher, 'fire' ); + const fooRange = ModelRange.createFromParentsAndOffsets( root, 0, root, 1 ); + const barRange = ModelRange.createFromParentsAndOffsets( root, 3, root, 6 ); - root.removeChildren( 0, root.childCount ); - root.appendChildren( [ new ModelElement( 'paragraph', { foo: 'bar' } ) ] ); + differStub.getMarkersToRemove = () => [ + { name: 'foo', range: fooRange }, + { name: 'bar', range: barRange } + ]; - const range = new ModelRange( new ModelPosition( root, [ 0 ] ), new ModelPosition( root, [ 0, 0 ] ) ); - model.applyOperation( wrapInDelta( new AttributeOperation( range, 'foo', 'bar', 'bar', 0 ) ) ); + dispatcher.convertChanges( differStub ); - expect( dispatcher.fire.called ).to.be.false; + expect( dispatcher.convertMarkerRemove.calledWith( 'foo', fooRange ) ); + expect( dispatcher.convertMarkerRemove.calledWith( 'bar', barRange ) ); } ); } ); @@ -292,30 +164,30 @@ describe( 'ModelConversionDispatcher', () => { } ); // Same here. - dispatcher.on( 'addAttribute', ( evt, data, consumable ) => { + dispatcher.on( 'attribute', ( evt, data, consumable ) => { const itemId = data.item.name ? data.item.name : '$text:' + data.item.data; const key = data.attributeKey; const value = data.attributeNewValue; - const log = 'addAttribute:' + key + ':' + value + ':' + itemId + ':' + data.range.start.path + ':' + data.range.end.path; + const log = 'attribute:' + key + ':' + value + ':' + itemId + ':' + data.range.start.path + ':' + data.range.end.path; loggedEvents.push( log ); - expect( evt.name ).to.equal( 'addAttribute:' + key + ':' + ( data.item.name || '$text' ) ); - expect( consumable.consume( data.item, 'addAttribute:' + key ) ).to.be.true; + expect( evt.name ).to.equal( 'attribute:' + key + ':' + ( data.item.name || '$text' ) ); + expect( consumable.consume( data.item, 'attribute:' + key ) ).to.be.true; } ); - dispatcher.convertInsertion( range ); + dispatcher.convertInsert( range ); // Check the data passed to called events and the order of them. expect( loggedEvents ).to.deep.equal( [ 'insert:$text:foo:0:3', - 'addAttribute:bold:true:$text:foo:0:3', + 'attribute:bold:true:$text:foo:0:3', 'insert:image:3:4', 'insert:$text:bar:4:7', 'insert:paragraph:7:8', - 'addAttribute:class:nice:paragraph:7:8', + 'attribute:class:nice:paragraph:7:8', 'insert:$text:xx:7,0:7,2', - 'addAttribute:italic:true:$text:xx:7,0:7,2' + 'attribute:italic:true:$text:xx:7,0:7,2' ] ); } ); @@ -330,289 +202,38 @@ describe( 'ModelConversionDispatcher', () => { dispatcher.on( 'insert:image', ( evt, data, consumable ) => { consumable.consume( data.item.getChild( 0 ), 'insert' ); - consumable.consume( data.item, 'addAttribute:bold' ); + consumable.consume( data.item, 'attribute:bold' ); } ); const range = ModelRange.createIn( root ); - dispatcher.convertInsertion( range ); + dispatcher.convertInsert( range ); expect( dispatcher.fire.calledWith( 'insert:image' ) ).to.be.true; - expect( dispatcher.fire.calledWith( 'addAttribute:src:image' ) ).to.be.true; - expect( dispatcher.fire.calledWith( 'addAttribute:title:image' ) ).to.be.true; + expect( dispatcher.fire.calledWith( 'attribute:src:image' ) ).to.be.true; + expect( dispatcher.fire.calledWith( 'attribute:title:image' ) ).to.be.true; expect( dispatcher.fire.calledWith( 'insert:$text' ) ).to.be.true; - expect( dispatcher.fire.calledWith( 'addAttribute:bold:image' ) ).to.be.false; + expect( dispatcher.fire.calledWith( 'attribute:bold:image' ) ).to.be.false; expect( dispatcher.fire.calledWith( 'insert:caption' ) ).to.be.false; } ); - - it( 'should fire marker converter if content is inserted into marker', () => { - const convertMarkerSpy = sinon.spy( dispatcher, 'convertMarker' ); - const paragraph1 = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) ); - const paragraph2 = new ModelElement( 'paragraph', null, new ModelText( 'bar' ) ); - root.appendChildren( [ paragraph1, paragraph2 ] ); - - const markerRange = ModelRange.createFromParentsAndOffsets( root, 0, root, 2 ); - model.markers.set( 'marker', markerRange ); - - const insertionRange = ModelRange.createOn( paragraph2 ); - dispatcher.convertInsertion( insertionRange ); - - sinon.assert.calledOnce( convertMarkerSpy ); - const callArgs = convertMarkerSpy.args[ 0 ]; - expect( callArgs[ 0 ] ).to.equal( 'addMarker' ); - expect( callArgs[ 1 ] ).to.equal( 'marker' ); - expect( callArgs[ 2 ].isEqual( markerRange.getIntersection( insertionRange ) ) ).to.be.true; - } ); - - it( 'should fire marker converter if content has marker', () => { - const convertMarkerSpy = sinon.spy( dispatcher, 'convertMarker' ); - const paragraph1 = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) ); - const paragraph2 = new ModelElement( 'paragraph', null, new ModelText( 'bar' ) ); - root.appendChildren( [ paragraph1, paragraph2 ] ); - - const markerRange = ModelRange.createIn( paragraph2 ); - model.markers.set( 'marker', markerRange ); - - const insertionRange = ModelRange.createOn( paragraph2 ); - dispatcher.convertInsertion( insertionRange ); - - sinon.assert.calledOnce( convertMarkerSpy ); - const callArgs = convertMarkerSpy.args[ 0 ]; - expect( callArgs[ 0 ] ).to.equal( 'addMarker' ); - expect( callArgs[ 1 ] ).to.equal( 'marker' ); - expect( callArgs[ 2 ].isEqual( markerRange ) ).to.be.true; - } ); - - it( 'should not fire marker conversion if content is inserted into element with custom highlight handling', () => { - sinon.spy( dispatcher, 'convertMarker' ); - - const text = new ModelText( 'abc' ); - const caption = new ModelElement( 'caption', null, text ); - const image = new ModelElement( 'image', null, caption ); - root.appendChildren( [ image ] ); - - // Create view elements that will be "mapped" to model elements. - const viewCaption = new ViewContainerElement( 'caption' ); - const viewFigure = new ViewContainerElement( 'figure', null, viewCaption ); - - // Create custom highlight handler mock. - viewFigure.setCustomProperty( 'addHighlight', () => {} ); - viewFigure.setCustomProperty( 'removeHighlight', () => {} ); - - // Create mapper mock. - dispatcher.conversionApi.mapper = { - toViewElement( modelElement ) { - if ( modelElement == image ) { - return viewFigure; - } else if ( modelElement == caption ) { - return viewCaption; - } - } - }; - - const markerRange = ModelRange.createFromParentsAndOffsets( root, 0, root, 1 ); - model.markers.set( 'marker', markerRange ); - - const insertionRange = ModelRange.createFromParentsAndOffsets( caption, 1, caption, 2 ); - dispatcher.convertInsertion( insertionRange ); - - expect( dispatcher.convertMarker.called ).to.be.false; - } ); - - it( 'should fire marker conversion if inserted into element with highlight handling but element is not in marker range', () => { - sinon.spy( dispatcher, 'convertMarker' ); - - const text = new ModelText( 'abc' ); - const caption = new ModelElement( 'caption', null, text ); - const image = new ModelElement( 'image', null, caption ); - root.appendChildren( [ image ] ); - - // Create view elements that will be "mapped" to model elements. - const viewCaption = new ViewContainerElement( 'caption' ); - const viewFigure = new ViewContainerElement( 'figure', null, viewCaption ); - - // Create custom highlight handler mock. - viewFigure.setCustomProperty( 'addHighlight', () => {} ); - viewFigure.setCustomProperty( 'removeHighlight', () => {} ); - - // Create mapper mock. - dispatcher.conversionApi.mapper = { - toViewElement( modelElement ) { - if ( modelElement == image ) { - return viewFigure; - } else if ( modelElement == caption ) { - return viewCaption; - } - } - }; - - const markerRange = ModelRange.createFromParentsAndOffsets( caption, 0, caption, 3 ); - model.markers.set( 'marker', markerRange ); - - const insertionRange = ModelRange.createFromParentsAndOffsets( caption, 2, caption, 3 ); - dispatcher.convertInsertion( insertionRange ); - - expect( dispatcher.convertMarker.called ).to.be.true; - } ); - } ); - - describe( 'convertMove', () => { - let loggedEvents; - - beforeEach( () => { - loggedEvents = []; - - dispatcher.on( 'remove', ( evt, data ) => { - const log = 'remove:' + data.sourcePosition.path + ':' + data.item.offsetSize; - loggedEvents.push( log ); - } ); - - dispatcher.on( 'insert', ( evt, data ) => { - const log = 'insert:' + data.range.start.path + ':' + data.range.end.path; - loggedEvents.push( log ); - } ); - } ); - - it( 'should first fire remove and then insert if moving "right"', () => { - // [ab]cd^ef -> cdabef - root.appendChildren( new ModelText( 'cdabef' ) ); - - const sourcePosition = ModelPosition.createFromParentAndOffset( root, 0 ); - const movedRange = ModelRange.createFromParentsAndOffsets( root, 2, root, 4 ); - - dispatcher.convertMove( sourcePosition, movedRange ); - - // after remove: cdef - // after insert: cd[ab]ef - expect( loggedEvents ).to.deep.equal( [ 'remove:0:2', 'insert:2:4' ] ); - } ); - - it( 'should first fire insert and then remove if moving "left"', () => { - // ab^cd[ef] -> abefcd - root.appendChildren( new ModelText( 'abefcd' ) ); - - const sourcePosition = ModelPosition.createFromParentAndOffset( root, 4 ); - const movedRange = ModelRange.createFromParentsAndOffsets( root, 2, root, 4 ); - - dispatcher.convertMove( sourcePosition, movedRange ); - - // after insert: ab[ef]cd[ef] - // after remove: ab[ef]cd - expect( loggedEvents ).to.deep.equal( [ 'insert:2:4', 'remove:6:2' ] ); - } ); - - it( 'should first fire insert and then remove when moving like in unwrap', () => { - // a^[xyz]b -> axyzb - root.appendChildren( [ - new ModelText( 'axyz' ), - new ModelElement( 'w' ), - new ModelText( 'b' ) - ] ); - - const sourcePosition = new ModelPosition( root, [ 1, 0 ] ); - const movedRange = ModelRange.createFromParentsAndOffsets( root, 1, root, 4 ); - - dispatcher.convertMove( sourcePosition, movedRange ); - - // before: a[xyz]b - // after insert: a[xyz][xyz]b - // after remove: a[xyz]b - expect( loggedEvents ).to.deep.equal( [ 'insert:1:4', 'remove:4,0:3' ] ); - } ); - - it( 'should first fire remove and then insert when moving like in wrap', () => { - // a[xyz]^b -> axyzb - root.appendChildren( [ - new ModelText( 'a' ), - new ModelElement( 'w', null, [ new ModelText( 'xyz' ) ] ), - new ModelText( 'b' ) - ] ); - - const sourcePosition = ModelPosition.createFromParentAndOffset( root, 1 ); - const movedRange = ModelRange.createFromPositionAndShift( new ModelPosition( root, [ 1, 0 ] ), 3 ); - - dispatcher.convertMove( sourcePosition, movedRange ); - - // before: a[xyz]b - // after remove: ab - // after insert: a[xyz]b - expect( loggedEvents ).to.deep.equal( [ 'remove:1:3', 'insert:1,0:1,3' ] ); - } ); } ); describe( 'convertRemove', () => { it( 'should fire event for removed range', () => { - root.appendChildren( new ModelText( 'foo' ) ); - doc.graveyard.appendChildren( new ModelText( 'bar' ) ); - - const range = ModelRange.createFromParentsAndOffsets( doc.graveyard, 0, doc.graveyard, 3 ); const loggedEvents = []; - dispatcher.on( 'remove', ( evt, data ) => { - const log = 'remove:' + data.sourcePosition.path + ':' + data.item.offsetSize; + dispatcher.on( 'remove:$text', ( evt, data ) => { + const log = 'remove:' + data.position.path + ':' + data.length; loggedEvents.push( log ); } ); - dispatcher.convertRemove( ModelPosition.createFromParentAndOffset( root, 3 ), range ); + dispatcher.convertRemove( ModelPosition.createFromParentAndOffset( root, 3 ), 3, '$text' ); expect( loggedEvents ).to.deep.equal( [ 'remove:3:3' ] ); } ); } ); - describe( 'convertAttribute', () => { - it( 'should fire event for every item in passed range', () => { - root.appendChildren( [ - new ModelText( 'foo', { bold: true } ), - new ModelElement( 'image', { bold: true } ), - new ModelElement( 'paragraph', { bold: true, class: 'nice' }, new ModelText( 'xx', { bold: true, italic: true } ) ) - ] ); - - const range = ModelRange.createIn( root ); - const loggedEvents = []; - - dispatcher.on( 'addAttribute', ( evt, data, consumable ) => { - const itemId = data.item.name ? data.item.name : '$text:' + data.item.data; - const key = data.attributeKey; - const value = data.attributeNewValue; - const log = 'addAttribute:' + key + ':' + value + ':' + itemId + ':' + data.range.start.path + ':' + data.range.end.path; - - loggedEvents.push( log ); - - expect( evt.name ).to.equal( 'addAttribute:' + key + ':' + ( data.item.name || '$text' ) ); - expect( consumable.consume( data.item, 'addAttribute:' + key ) ).to.be.true; - } ); - - dispatcher.convertAttribute( 'addAttribute', range, 'bold', null, true ); - - expect( loggedEvents ).to.deep.equal( [ - 'addAttribute:bold:true:$text:foo:0:3', - 'addAttribute:bold:true:image:3:4', - 'addAttribute:bold:true:paragraph:4:5', - 'addAttribute:bold:true:$text:xx:4,0:4,2' - ] ); - } ); - - it( 'should not fire events for already consumed parts of model', () => { - root.appendChildren( [ - new ModelElement( 'element', null, new ModelElement( 'inside' ) ) - ] ); - - sinon.spy( dispatcher, 'fire' ); - - dispatcher.on( 'removeAttribute:attr:element', ( evt, data, consumable ) => { - consumable.consume( data.item.getChild( 0 ), 'removeAttribute:attr' ); - } ); - - const range = ModelRange.createIn( root ); - - dispatcher.convertAttribute( 'removeAttribute', range, 'attr', 'value', null ); - - expect( dispatcher.fire.calledWith( 'removeAttribute:attr:element' ) ).to.be.true; - expect( dispatcher.fire.calledWith( 'removeAttribute:attr:inside' ) ).to.be.false; - } ); - } ); - describe( 'convertSelection', () => { beforeEach( () => { dispatcher.off( 'selection' ); @@ -636,7 +257,7 @@ describe( 'ModelConversionDispatcher', () => { } ); it( 'should prepare correct list of consumable values', () => { - model.enqueueChange( writer => { + model.change( writer => { writer.setAttribute( 'bold', true, ModelRange.createIn( root ) ); writer.setAttribute( 'italic', true, ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ) ); } ); @@ -653,7 +274,7 @@ describe( 'ModelConversionDispatcher', () => { it( 'should fire attributes events for selection', () => { sinon.spy( dispatcher, 'fire' ); - model.enqueueChange( writer => { + model.change( writer => { writer.setAttribute( 'bold', true, ModelRange.createIn( root ) ); writer.setAttribute( 'italic', true, ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ) ); } ); @@ -671,7 +292,7 @@ describe( 'ModelConversionDispatcher', () => { consumable.consume( data.selection, 'selectionAttribute:bold' ); } ); - model.enqueueChange( writer => { + model.change( writer => { writer.setAttribute( 'bold', true, ModelRange.createIn( root ) ); writer.setAttribute( 'italic', true, ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ) ); } ); @@ -750,37 +371,30 @@ describe( 'ModelConversionDispatcher', () => { } ); } ); - describe( 'convertMarker', () => { - let range; + describe( 'convertMarkerAdd', () => { + let range, element, text; beforeEach( () => { - const element = new ModelElement( 'paragraph', null, [ new ModelText( 'foo bar baz' ) ] ); + text = new ModelText( 'foo bar baz' ); + element = new ModelElement( 'paragraph', null, [ text ] ); root.appendChildren( [ element ] ); range = ModelRange.createFromParentsAndOffsets( element, 0, element, 4 ); } ); - it( 'should fire event based on passed parameters', () => { + it( 'should fire addMarker event', () => { sinon.spy( dispatcher, 'fire' ); - dispatcher.convertMarker( 'addMarker', 'name', range ); + dispatcher.convertMarkerAdd( 'name', range ); expect( dispatcher.fire.calledWith( 'addMarker:name' ) ).to.be.true; - - dispatcher.convertMarker( 'removeMarker', 'name', range ); - - expect( dispatcher.fire.calledWith( 'removeMarker:name' ) ).to.be.true; } ); - it( 'should not convert marker if it is added in graveyard', () => { + it( 'should not convert marker if it is in graveyard', () => { const gyRange = ModelRange.createFromParentsAndOffsets( doc.graveyard, 0, doc.graveyard, 0 ); sinon.spy( dispatcher, 'fire' ); - dispatcher.convertMarker( 'addMarker', 'name', gyRange ); - - expect( dispatcher.fire.called ).to.be.false; - - dispatcher.convertMarker( 'removeMarker', 'name', gyRange ); + dispatcher.convertMarkerAdd( 'name', gyRange ); expect( dispatcher.fire.called ).to.be.false; } ); @@ -790,118 +404,120 @@ describe( 'ModelConversionDispatcher', () => { const eleRange = ModelRange.createFromParentsAndOffsets( element, 1, element, 2 ); sinon.spy( dispatcher, 'fire' ); - dispatcher.convertMarker( 'addMarker', 'name', eleRange ); + dispatcher.convertMarkerAdd( 'name', eleRange ); expect( dispatcher.fire.called ).to.be.false; + } ); - dispatcher.convertMarker( 'removeMarker', 'name', eleRange ); + it( 'should fire conversion for each item in the range', () => { + range = ModelRange.createIn( root ); - expect( dispatcher.fire.called ).to.be.false; - } ); + const items = []; - it( 'should prepare consumable values', () => { dispatcher.on( 'addMarker:name', ( evt, data, consumable ) => { - expect( consumable.test( data.item, 'addMarker:name' ) ).to.be.true; - } ); + expect( data.markerName ).to.equal( 'name' ); + expect( data.markerRange.isEqual( range ) ).to.be.true; + expect( consumable.test( data.item, 'addMarker:name' ) ); - dispatcher.on( 'removeMarker:name', ( evt, data, consumable ) => { - expect( consumable.test( data.item, 'removeMarker:name' ) ).to.be.true; + items.push( data.item ); } ); - dispatcher.convertMarker( 'addMarker', 'name', range ); - dispatcher.convertMarker( 'removeMarker', 'name', range ); + dispatcher.convertMarkerAdd( 'name', range ); + + expect( items[ 0 ] ).to.equal( element ); + expect( items[ 1 ].data ).to.equal( text.data ); } ); - it( 'should fire conversion for each item in the range', () => { - const element = new ModelElement( 'paragraph', null, [ new ModelText( 'foo bar baz' ) ] ); - root.appendChildren( [ element ] ); + it( 'should be possible to override', () => { range = ModelRange.createIn( root ); - const addMarkerData = []; - const removeMarkerData = []; - - dispatcher.on( 'addMarker:name', ( evt, data ) => addMarkerData.push( data ) ); - dispatcher.on( 'removeMarker:name', ( evt, data ) => removeMarkerData.push( data ) ); + const addMarkerSpy = sinon.spy(); + const highAddMarkerSpy = sinon.spy(); - dispatcher.convertMarker( 'addMarker', 'name', range ); - dispatcher.convertMarker( 'removeMarker', 'name', range ); + dispatcher.on( 'addMarker:marker', addMarkerSpy ); - // Check if events for all elements were fired. - let i = 0; - for ( const val of range ) { - const nodeInRange = val.item; - const addData = addMarkerData[ i ]; - const removeData = removeMarkerData[ i ]; + dispatcher.on( 'addMarker:marker', evt => { + highAddMarkerSpy(); - expect( addData.markerName ).to.equal( 'name' ); - expect( addData.markerRange ).to.equal( range ); - expect( addData.range.isEqual( ModelRange.createOn( nodeInRange ) ) ); + evt.stop(); + }, { priority: 'high' } ); - expect( removeData.markerName ).to.equal( 'name' ); - expect( removeData.markerRange ).to.equal( range ); - expect( removeData.range.isEqual( ModelRange.createOn( nodeInRange ) ) ); + dispatcher.convertMarkerAdd( 'marker', range ); - if ( nodeInRange.is( 'textProxy' ) ) { - expect( nodeInRange.data ).to.equal( addData.item.data ); - expect( nodeInRange.data ).to.equal( removeData.item.data ); - } else { - expect( nodeInRange ).to.equal( addData.item ); - expect( nodeInRange ).to.equal( removeData.item ); - } + expect( addMarkerSpy.called ).to.be.false; - i++; - } + // Called once for each item, twice total. + expect( highAddMarkerSpy.calledTwice ).to.be.true; } ); + } ); + + describe( 'convertMarkerRemove', () => { + let range, element, text; - it( 'should not fire events for already consumed items', () => { - const element = new ModelElement( 'paragraph', null, [ new ModelText( 'foo bar baz' ) ] ); + beforeEach( () => { + text = new ModelText( 'foo bar baz' ); + element = new ModelElement( 'paragraph', null, [ text ] ); root.appendChildren( [ element ] ); - const range = ModelRange.createIn( root ); - const addMarkerSpy = sinon.spy( ( evt, data, consumable ) => { - // Consume all items in marker range. - for ( const value of data.markerRange ) { - consumable.consume( value.item, 'addMarker:marker' ); - } - } ); - const removeMarkerSpy = sinon.spy( ( evt, data, consumable ) => { - // Consume all items in marker range. - for ( const value of data.markerRange ) { - consumable.consume( value.item, 'removeMarker:marker' ); - } - } ); + range = ModelRange.createFromParentsAndOffsets( element, 0, element, 4 ); + } ); - dispatcher.on( 'addMarker:marker', addMarkerSpy ); - dispatcher.on( 'addMarker:marker', removeMarkerSpy ); + it( 'should fire removeMarker event', () => { + sinon.spy( dispatcher, 'fire' ); - dispatcher.convertMarker( 'addMarker', 'marker', range ); - dispatcher.convertMarker( 'removeMarker', 'marker', range ); + dispatcher.convertMarkerRemove( 'name', range ); - sinon.assert.calledOnce( addMarkerSpy ); - sinon.assert.calledOnce( removeMarkerSpy ); + expect( dispatcher.fire.calledWith( 'removeMarker:name' ) ).to.be.true; } ); - it( 'should fire event for collapsed marker', () => { - const range = ModelRange.createFromParentsAndOffsets( root, 1, root, 1 ); - const addMarkerSpy = sinon.spy( ( evt, data, consumable ) => { - expect( data.markerRange ).to.equal( range ); - expect( data.markerName ).to.equal( 'marker' ); - expect( consumable.test( data.markerRange, evt.name ) ).to.be.true; - } ); - const removeMarkerSpy = sinon.spy( ( evt, data, consumable ) => { - expect( data.markerRange ).to.equal( range ); - expect( data.markerName ).to.equal( 'marker' ); - expect( consumable.test( data.markerRange, evt.name ) ).to.be.true; + it( 'should not convert marker if it is in graveyard', () => { + const gyRange = ModelRange.createFromParentsAndOffsets( doc.graveyard, 0, doc.graveyard, 0 ); + sinon.spy( dispatcher, 'fire' ); + + dispatcher.convertMarkerRemove( 'name', gyRange ); + + expect( dispatcher.fire.called ).to.be.false; + } ); + + it( 'should not convert marker if it is not in model root', () => { + const element = new ModelElement( 'element', null, new ModelText( 'foo' ) ); + const eleRange = ModelRange.createFromParentsAndOffsets( element, 1, element, 2 ); + sinon.spy( dispatcher, 'fire' ); + + dispatcher.convertMarkerRemove( 'name', eleRange ); + + expect( dispatcher.fire.called ).to.be.false; + } ); + + it( 'should fire conversion for the range', () => { + range = ModelRange.createIn( root ); + + dispatcher.on( 'addMarker:name', ( evt, data ) => { + expect( data.markerName ).to.equal( 'name' ); + expect( data.markerRange.isEqual( range ) ).to.be.true; } ); - dispatcher.on( 'addMarker:marker', addMarkerSpy ); - dispatcher.on( 'addMarker:marker', removeMarkerSpy ); + dispatcher.convertMarkerRemove( 'name', range ); + } ); + + it( 'should be possible to override', () => { + range = ModelRange.createIn( root ); + + const removeMarkerSpy = sinon.spy(); + const highRemoveMarkerSpy = sinon.spy(); + + dispatcher.on( 'removeMarker:marker', removeMarkerSpy ); + + dispatcher.on( 'removeMarker:marker', evt => { + highRemoveMarkerSpy(); + + evt.stop(); + }, { priority: 'high' } ); - dispatcher.convertMarker( 'addMarker', 'marker', range ); - dispatcher.convertMarker( 'removeMarker', 'marker', range ); + dispatcher.convertMarkerRemove( 'marker', range ); - sinon.assert.calledOnce( addMarkerSpy ); - sinon.assert.calledOnce( removeMarkerSpy ); + expect( removeMarkerSpy.called ).to.be.false; + expect( highRemoveMarkerSpy.calledOnce ).to.be.true; } ); } ); } ); diff --git a/tests/dev-utils/enableenginedebug.js b/tests/dev-utils/enableenginedebug.js index 22f63b675..57cebac74 100644 --- a/tests/dev-utils/enableenginedebug.js +++ b/tests/dev-utils/enableenginedebug.js @@ -802,12 +802,14 @@ describe( 'debug tools', () => { const modelRoot = modelDoc.getRoot(); const viewDoc = editor.editing.view; - const insert = new InsertOperation( ModelPosition.createAt( modelRoot, 0 ), new ModelText( 'foobar' ), 0 ); - model.applyOperation( wrapInDelta( insert ) ); + model.change( () => { + const insert = new InsertOperation( ModelPosition.createAt( modelRoot, 0 ), new ModelText( 'foobar' ), 0 ); + model.applyOperation( wrapInDelta( insert ) ); - const graveyard = modelDoc.graveyard; - const remove = new RemoveOperation( ModelPosition.createAt( modelRoot, 1 ), 2, ModelPosition.createAt( graveyard, 0 ), 1 ); - model.applyOperation( wrapInDelta( remove ) ); + const graveyard = modelDoc.graveyard; + const remove = new RemoveOperation( ModelPosition.createAt( modelRoot, 1 ), 2, ModelPosition.createAt( graveyard, 0 ), 1 ); + model.applyOperation( wrapInDelta( remove ) ); + } ); log.reset(); @@ -850,13 +852,6 @@ describe( 'debug tools', () => { '
' ); - viewDoc.log( 1 ); - expectLog( - '
' + - '\n\tfoobar' + - '\n
' - ); - viewDoc.log( 2 ); expectLog( '
' + diff --git a/tests/dev-utils/model.js b/tests/dev-utils/model.js index abee4b96a..c901c1778 100644 --- a/tests/dev-utils/model.js +++ b/tests/dev-utils/model.js @@ -244,8 +244,7 @@ describe( 'model test utils', () => { ] ); expect( stringify( root ) ).to.equal( - // Because of https://github.com/ckeditor/ckeditor5-engine/issues/562 attributes are not merged - '<$text bold="true">foobar<$text bold="true"><$text italic="true">bom' + + '<$text bold="true">foobar<$text bold="true" italic="true">bom' + '<$text bold="true" underline="true">pom' ); } ); diff --git a/tests/manual/highlight.html b/tests/manual/highlight.html index 9127190da..df65f02e5 100644 --- a/tests/manual/highlight.html +++ b/tests/manual/highlight.html @@ -1,14 +1,14 @@