Skip to content
This repository was archived by the owner on Jun 26, 2020. It is now read-only.

Commit 6479bfd

Browse files
author
Piotr Jasiun
authored
Merge pull request #1206 from ckeditor/t/1172-b
Other: Refactoring: Conversion refactoring. Introduced `model.Differ`. Changes now will be converted after all changes in a change block are done. Closes #1172.
2 parents a4919d9 + 57d8e04 commit 6479bfd

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+4102
-2980
lines changed

src/controller/datacontroller.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export default class DataController {
167167
const viewDocumentFragment = new ViewDocumentFragment();
168168
this.mapper.bindElements( modelElementOrFragment, viewDocumentFragment );
169169

170-
this.modelToView.convertInsertion( modelRange );
170+
this.modelToView.convertInsert( modelRange );
171171

172172
this.mapper.clearBindings();
173173

src/controller/editingcontroller.js

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* @module engine/controller/editingcontroller
88
*/
99

10+
import ModelDiffer from '../model/differ';
1011
import ViewDocument from '../view/document';
1112
import Mapper from '../conversion/mapper';
1213
import ModelConversionDispatcher from '../conversion/modelconversiondispatcher';
@@ -64,10 +65,9 @@ export default class EditingController {
6465
this.mapper = new Mapper();
6566

6667
/**
67-
* Model to view conversion dispatcher, which converts changes from the model to
68-
* {@link #view editing view}.
68+
* Model to view conversion dispatcher, which converts changes from the model to {@link #view the editing view}.
6969
*
70-
* To attach model to view converter to the editing pipeline you need to add lister to this property:
70+
* To attach model-to-view converter to the editing pipeline you need to add a listener to this dispatcher:
7171
*
7272
* editing.modelToView( 'insert:$element', customInsertConverter );
7373
*
@@ -83,36 +83,60 @@ export default class EditingController {
8383
viewSelection: this.view.selection
8484
} );
8585

86-
// Convert changes in model to view.
87-
this.listenTo( this.model.document, 'change', ( evt, type, changes ) => {
88-
this.modelToView.convertChange( type, changes );
89-
}, { priority: 'low' } );
86+
// Model differ object. It's role is to buffer changes done on model and then calculates a diff of those changes.
87+
// The diff is then passed to model conversion dispatcher which generates proper events and kicks-off conversion.
88+
const modelDiffer = new ModelDiffer();
9089

91-
// Convert model selection to view.
92-
this.listenTo( this.model.document, 'changesDone', () => {
93-
const selection = this.model.document.selection;
90+
// Before an operation is applied on model, buffer the change in differ.
91+
this.listenTo( this.model, 'applyOperation', ( evt, args ) => {
92+
const operation = args[ 0 ];
9493

95-
this.modelToView.convertSelection( selection );
96-
this.view.render();
97-
}, { priority: 'low' } );
94+
if ( operation.isDocumentOperation ) {
95+
modelDiffer.bufferOperation( operation );
96+
}
97+
}, { priority: 'high' } );
9898

99-
// Convert model markers changes.
99+
// Buffer marker changes.
100+
// This is not covered in buffering operations because markers may change outside of them (when they
101+
// are modified using `model.document.markers` collection, not through `MarkerOperation`).
100102
this.listenTo( this.model.markers, 'add', ( evt, marker ) => {
101-
this.modelToView.convertMarker( 'addMarker', marker.name, marker.getRange() );
103+
// Whenever a new marker is added, buffer that change.
104+
modelDiffer.bufferMarkerChange( marker.name, null, marker.getRange() );
105+
106+
// Whenever marker changes, buffer that.
107+
marker.on( 'change', ( evt, oldRange ) => {
108+
modelDiffer.bufferMarkerChange( marker.name, oldRange, marker.getRange() );
109+
} );
102110
} );
103111

104112
this.listenTo( this.model.markers, 'remove', ( evt, marker ) => {
105-
this.modelToView.convertMarker( 'removeMarker', marker.name, marker.getRange() );
113+
// Whenever marker is removed, buffer that change.
114+
modelDiffer.bufferMarkerChange( marker.name, marker.getRange(), null );
106115
} );
107116

108-
// Convert view selection to model.
117+
// When all changes are done, get the model diff containing all the changes and convert them to view and then render to DOM.
118+
this.listenTo( this.model, 'changesDone', () => {
119+
// Convert changes stored in `modelDiffer`.
120+
this.modelToView.convertChanges( modelDiffer );
121+
122+
// Reset model diff object. When next operation is applied, new diff will be created.
123+
modelDiffer.reset();
124+
125+
// After the view is ready, convert selection from model to view.
126+
this.modelToView.convertSelection( this.model.document.selection );
127+
128+
// When everything is converted to the view, render it to DOM.
129+
this.view.render();
130+
}, { priority: 'low' } );
131+
132+
// Convert selection from view to model.
109133
this.listenTo( this.view, 'selectionChange', convertSelectionChange( this.model, this.mapper ) );
110134

111-
// Attach default content converters.
135+
// Attach default model converters.
112136
this.modelToView.on( 'insert:$text', insertText(), { priority: 'lowest' } );
113137
this.modelToView.on( 'remove', remove(), { priority: 'low' } );
114138

115-
// Attach default selection converters.
139+
// Attach default model selection converters.
116140
this.modelToView.on( 'selection', clearAttributes(), { priority: 'low' } );
117141
this.modelToView.on( 'selection', clearFakeSelection(), { priority: 'low' } );
118142
this.modelToView.on( 'selection', convertRangeSelection(), { priority: 'low' } );

src/conversion/buildmodelconverter.js

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,12 @@
1010
import {
1111
insertElement,
1212
insertUIElement,
13-
setAttribute,
14-
removeAttribute,
1513
removeUIElement,
16-
wrapItem,
17-
unwrapItem,
14+
changeAttribute,
15+
wrap,
1816
highlightText,
19-
highlightElement
17+
highlightElement,
18+
removeHighlight
2019
} from './model-to-view-converters';
2120

2221
import { convertSelectionAttribute, convertSelectionMarker } from './model-selection-to-view-converters';
@@ -254,15 +253,13 @@ class ModelConverterBuilder {
254253

255254
dispatcher.on( 'insert:' + this._from.name, insertElement( element ), { priority } );
256255
} else if ( this._from.type == 'attribute' ) {
257-
// From model attribute to view element -> wrap and unwrap.
256+
// From model attribute to view element -> wrap.
258257
element = typeof element == 'string' ? new ViewAttributeElement( element ) : element;
259258

260-
dispatcher.on( 'addAttribute:' + this._from.key, wrapItem( element ), { priority } );
261-
dispatcher.on( 'changeAttribute:' + this._from.key, wrapItem( element ), { priority } );
262-
dispatcher.on( 'removeAttribute:' + this._from.key, unwrapItem( element ), { priority } );
263-
259+
dispatcher.on( 'attribute:' + this._from.key, wrap( element ), { priority } );
264260
dispatcher.on( 'selectionAttribute:' + this._from.key, convertSelectionAttribute( element ), { priority } );
265-
} else { // From marker to element.
261+
} else {
262+
// From marker to element.
266263
const priority = this._from.priority === null ? 'normal' : this._from.priority;
267264

268265
element = typeof element == 'string' ? new ViewUIElement( element ) : element;
@@ -326,12 +323,10 @@ class ModelConverterBuilder {
326323
}
327324

328325
for ( const dispatcher of this._dispatchers ) {
329-
// Separate converters for converting texts and elements inside marker's range.
330326
dispatcher.on( 'addMarker:' + this._from.name, highlightText( highlightDescriptor ), { priority } );
331327
dispatcher.on( 'addMarker:' + this._from.name, highlightElement( highlightDescriptor ), { priority } );
332328

333-
dispatcher.on( 'removeMarker:' + this._from.name, highlightText( highlightDescriptor ), { priority } );
334-
dispatcher.on( 'removeMarker:' + this._from.name, highlightElement( highlightDescriptor ), { priority } );
329+
dispatcher.on( 'removeMarker:' + this._from.name, removeHighlight( highlightDescriptor ), { priority } );
335330

336331
dispatcher.on( 'selectionMarker:' + this._from.name, convertSelectionMarker( highlightDescriptor ), { priority } );
337332
}
@@ -383,7 +378,7 @@ class ModelConverterBuilder {
383378

384379
if ( !keyOrCreator ) {
385380
// If `keyOrCreator` is not set, we assume default behavior which is 1:1 attribute re-write.
386-
// This is also a default behavior for `setAttribute` converter when no attribute creator is passed.
381+
// This is also a default behavior for `changeAttribute` converter when no attribute creator is passed.
387382
attributeCreator = undefined;
388383
} else if ( typeof keyOrCreator == 'string' ) {
389384
// `keyOrCreator` is an attribute key.
@@ -407,9 +402,7 @@ class ModelConverterBuilder {
407402
for ( const dispatcher of this._dispatchers ) {
408403
const options = { priority: this._from.priority || 'normal' };
409404

410-
dispatcher.on( 'addAttribute:' + this._from.key, setAttribute( attributeCreator ), options );
411-
dispatcher.on( 'changeAttribute:' + this._from.key, setAttribute( attributeCreator ), options );
412-
dispatcher.on( 'removeAttribute:' + this._from.key, removeAttribute( attributeCreator ), options );
405+
dispatcher.on( 'attribute:' + this._from.key, changeAttribute( attributeCreator ), options );
413406
}
414407
}
415408
}

src/conversion/mapper.js

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -110,23 +110,43 @@ export default class Mapper {
110110
/**
111111
* Unbinds given {@link module:engine/view/element~Element view element} from the map.
112112
*
113+
* **Note:** view-to-model binding will be removed, if it existed. However, corresponding model-to-view binding
114+
* will be removed only if model element is still bound to passed `viewElement`.
115+
*
116+
* This behavior lets for re-binding model element to another view element without fear of losing the new binding
117+
* when the previously bound view element is unbound.
118+
*
113119
* @param {module:engine/view/element~Element} viewElement View element to unbind.
114120
*/
115121
unbindViewElement( viewElement ) {
116122
const modelElement = this.toModelElement( viewElement );
117123

118-
this._unbindElements( modelElement, viewElement );
124+
this._viewToModelMapping.delete( viewElement );
125+
126+
if ( this._modelToViewMapping.get( modelElement ) == viewElement ) {
127+
this._modelToViewMapping.delete( modelElement );
128+
}
119129
}
120130

121131
/**
122132
* Unbinds given {@link module:engine/model/element~Element model element} from the map.
123133
*
134+
* **Note:** model-to-view binding will be removed, if it existed. However, corresponding view-to-model binding
135+
* will be removed only if view element is still bound to passed `modelElement`.
136+
*
137+
* This behavior lets for re-binding view element to another model element without fear of losing the new binding
138+
* when the previously bound model element is unbound.
139+
*
124140
* @param {module:engine/model/element~Element} modelElement Model element to unbind.
125141
*/
126142
unbindModelElement( modelElement ) {
127143
const viewElement = this.toViewElement( modelElement );
128144

129-
this._unbindElements( modelElement, viewElement );
145+
this._modelToViewMapping.delete( modelElement );
146+
147+
if ( this._viewToModelMapping.get( viewElement ) == modelElement ) {
148+
this._viewToModelMapping.delete( viewElement );
149+
}
130150
}
131151

132152
/**
@@ -202,12 +222,16 @@ export default class Mapper {
202222
*
203223
* @fires modelToViewPosition
204224
* @param {module:engine/model/position~Position} modelPosition Model position.
225+
* @param {Object} [options] Additional options for position mapping process.
226+
* @param {Boolean} [options.isPhantom=false] Should be set to `true` if the model position to map is pointing to a place
227+
* in model tree which no longer exists. For example, it could be an end of a removed model range.
205228
* @returns {module:engine/view/position~Position} Corresponding view position.
206229
*/
207-
toViewPosition( modelPosition ) {
230+
toViewPosition( modelPosition, options = { isPhantom: false } ) {
208231
const data = {
209232
modelPosition,
210-
mapper: this
233+
mapper: this,
234+
isPhantom: options.isPhantom
211235
};
212236

213237
this.fire( 'modelToViewPosition', data );
@@ -292,18 +316,6 @@ export default class Mapper {
292316
return modelOffset;
293317
}
294318

295-
/**
296-
* Removes binding between given elements.
297-
*
298-
* @private
299-
* @param {module:engine/model/element~Element} modelElement Model element to unbind.
300-
* @param {module:engine/view/element~Element} viewElement View element to unbind.
301-
*/
302-
_unbindElements( modelElement, viewElement ) {
303-
this._viewToModelMapping.delete( viewElement );
304-
this._modelToViewMapping.delete( modelElement );
305-
}
306-
307319
/**
308320
* Gets the length of the view element in the model.
309321
*
@@ -460,19 +472,33 @@ export default class Mapper {
460472
* }
461473
* } );
462474
*
463-
* **Note:** keep in mind that custom callback provided for this event should use provided `data.modelPosition` only to check
464-
* what is before the position (or position's parent). This is important when model-to-view position mapping is used in
465-
* remove change conversion. Model after the removed position (that is being mapped) does not correspond to view, so it cannot be used:
475+
* **Note:** keep in mind that sometimes a "phantom" model position is being converted. "Phantom" model position is
476+
* a position that points to a non-existing place in model. Such position might still be valid for conversion, though
477+
* (it would point to a correct place in view when converted). One example of such situation is when a range is
478+
* removed from model, there may be a need to map the range's end (which is no longer valid model position). To
479+
* handle such situation, check `data.isPhantom` flag:
480+
*
481+
* // Assume that there is "customElement" model element and whenever position is before it, we want to move it
482+
* // to the inside of the view element bound to "customElement".
483+
* mapper.on( 'modelToViewPosition', ( evt, data ) => {
484+
* if ( data.isPhantom ) {
485+
* return;
486+
* }
466487
*
467-
* // Incorrect:
468-
* const modelElement = data.modelPosition.nodeAfter;
469-
* const viewElement = data.mapper.toViewElement( modelElement );
470-
* // ... Do something with `viewElement` and set `data.viewPosition`.
488+
* // Below line might crash for phantom position that does not exist in model.
489+
* const sibling = data.modelPosition.nodeBefore;
490+
*
491+
* // Check if this is the element we are interested in.
492+
* if ( !sibling.is( 'customElement' ) ) {
493+
* return;
494+
* }
471495
*
472-
* // Correct:
473-
* const prevModelElement = data.modelPosition.nodeBefore;
474-
* const prevViewElement = data.mapper.toViewElement( prevModelElement );
475-
* // ... Use `prevViewElement` to find correct `data.viewPosition`.
496+
* const viewElement = data.mapper.toViewElement( sibling );
497+
*
498+
* data.viewPosition = new ViewPosition( sibling, 0 );
499+
*
500+
* evt.stop();
501+
* } );
476502
*
477503
* **Note:** default mapping callback is provided with `low` priority setting and does not cancel the event, so it is possible to
478504
* attach a custom callback after default callback and also use `data.viewPosition` calculated by default callback

0 commit comments

Comments
 (0)