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

Commit 5627993

Browse files
author
Piotr Jasiun
authored
Merge pull request #885 from ckeditor/t/884
Other: Default conversion.Mapper position mapping algorithms are now added as callbacks with low priority and are fired only if earlier callbacks did not provide a result. Closes #884. BREAKING CHANGES: Since default position mapping algorithms are attached with low priority, custom position mapping callbacks added with higher priority won't receive position calculated by default algorithms in data. To execute default position mapping algorithms and use their value, hook custom callback with lower priority.
2 parents 45f0f33 + aad3d40 commit 5627993

File tree

6 files changed

+248
-107
lines changed

6 files changed

+248
-107
lines changed

src/conversion/mapper.js

Lines changed: 113 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,36 @@ export default class Mapper {
6161
* @member {Map}
6262
*/
6363
this._viewToModelLengthCallbacks = new Map();
64+
65+
// Default mapper algorithm for mapping model position to view position.
66+
this.on( 'modelToViewPosition', ( evt, data ) => {
67+
if ( data.viewPosition ) {
68+
return;
69+
}
70+
71+
let viewContainer = this._modelToViewMapping.get( data.modelPosition.parent );
72+
73+
data.viewPosition = this._findPositionIn( viewContainer, data.modelPosition.offset );
74+
}, { priority: 'low' } );
75+
76+
// Default mapper algorithm for mapping view position to model position.
77+
this.on( 'viewToModelPosition', ( evt, data ) => {
78+
if ( data.modelPosition ) {
79+
return;
80+
}
81+
82+
let viewBlock = data.viewPosition.parent;
83+
let modelParent = this._viewToModelMapping.get( viewBlock );
84+
85+
while ( !modelParent ) {
86+
viewBlock = viewBlock.parent;
87+
modelParent = this._viewToModelMapping.get( viewBlock );
88+
}
89+
90+
let modelOffset = this._toModelOffset( data.viewPosition.parent, data.viewPosition.offset, viewBlock );
91+
92+
data.modelPosition = ModelPosition.createFromParentAndOffset( modelParent, modelOffset );
93+
}, { priority: 'low' } );
6494
}
6595

6696
/**
@@ -159,7 +189,6 @@ export default class Mapper {
159189
toModelPosition( viewPosition ) {
160190
const data = {
161191
viewPosition: viewPosition,
162-
modelPosition: this._defaultToModelPosition( viewPosition ),
163192
mapper: this
164193
};
165194

@@ -168,19 +197,6 @@ export default class Mapper {
168197
return data.modelPosition;
169198
}
170199

171-
/**
172-
* Maps model position to view position using default mapper algorithm.
173-
*
174-
* @private
175-
* @param {module:engine/model/position~Position} modelPosition
176-
* @returns {module:engine/view/position~Position} View position mapped from model position.
177-
*/
178-
_defaultToViewPosition( modelPosition ) {
179-
let viewContainer = this._modelToViewMapping.get( modelPosition.parent );
180-
181-
return this._findPositionIn( viewContainer, modelPosition.offset );
182-
}
183-
184200
/**
185201
* Gets the corresponding view position.
186202
*
@@ -190,7 +206,6 @@ export default class Mapper {
190206
*/
191207
toViewPosition( modelPosition ) {
192208
const data = {
193-
viewPosition: this._defaultToViewPosition( modelPosition ),
194209
modelPosition: modelPosition,
195210
mapper: this
196211
};
@@ -200,27 +215,6 @@ export default class Mapper {
200215
return data.viewPosition;
201216
}
202217

203-
/**
204-
* Maps view position to model position using default mapper algorithm.
205-
*
206-
* @private
207-
* @param {module:engine/view/position~Position} viewPosition
208-
* @returns {module:engine/model/position~Position} Model position mapped from view position.
209-
*/
210-
_defaultToModelPosition( viewPosition ) {
211-
let viewBlock = viewPosition.parent;
212-
let modelParent = this._viewToModelMapping.get( viewBlock );
213-
214-
while ( !modelParent ) {
215-
viewBlock = viewBlock.parent;
216-
modelParent = this._viewToModelMapping.get( viewBlock );
217-
}
218-
219-
let modelOffset = this._toModelOffset( viewPosition.parent, viewPosition.offset, viewBlock );
220-
221-
return ModelPosition.createFromParentAndOffset( modelParent, modelOffset );
222-
}
223-
224218
/**
225219
* Registers a callback that evaluates the length in the model of a view element with given name.
226220
*
@@ -442,65 +436,89 @@ export default class Mapper {
442436
// Otherwise, just return the given position.
443437
return viewPosition;
444438
}
445-
}
446439

447-
mix( Mapper, EmitterMixin );
440+
/**
441+
* Fired for each model-to-view position mapping request. The purpose of this event is to enable custom model-to-view position
442+
* mapping. Callbacks added to this event take {@link module:engine/model/position~Position model position} and are expected to calculate
443+
* {@link module:engine/view/position~Position view position}. Calculated view position should be added as `viewPosition` value in
444+
* `data` object that is passed as one of parameters to the event callback.
445+
*
446+
* // Assume that "captionedImage" model element is converted to <img> and following <span> elements in view,
447+
* // and the model element is bound to <img> element. Force mapping model positions inside "captionedImage" to that <span> element.
448+
* mapper.on( 'modelToViewPosition', ( evt, data ) => {
449+
* const positionParent = modelPosition.parent;
450+
*
451+
* if ( positionParent.name == 'captionedImage' ) {
452+
* const viewImg = data.mapper.toViewElement( positionParent );
453+
* const viewCaption = viewImg.nextSibling; // The <span> element.
454+
*
455+
* data.viewPosition = new ViewPosition( viewCaption, modelPosition.offset );
456+
*
457+
* // Stop the event if other callbacks should not modify calculated value.
458+
* evt.stop();
459+
* }
460+
* } );
461+
*
462+
* **Note:** keep in mind that custom callback provided for this event should use provided `data.modelPosition` only to check
463+
* what is before the position (or position's parent). This is important when model-to-view position mapping is used in
464+
* remove change conversion. Model after the removed position (that is being mapped) does not correspond to view, so it cannot be used:
465+
*
466+
* // Incorrect:
467+
* const modelElement = data.modelPosition.nodeAfter;
468+
* const viewElement = data.mapper.toViewElement( modelElement );
469+
* // ... Do something with `viewElement` and set `data.viewPosition`.
470+
*
471+
* // Correct:
472+
* const prevModelElement = data.modelPosition.nodeBefore;
473+
* const prevViewElement = data.mapper.toViewElement( prevModelElement );
474+
* // ... Use `prevViewElement` to find correct `data.viewPosition`.
475+
*
476+
* **Note:** default mapping callback is provided with `low` priority setting and does not cancel the event, so it is possible to attach
477+
* a custom callback after default callback and also use `data.viewPosition` calculated by default callback (for example to fix it).
478+
*
479+
* **Note:** default mapping callback will not fire if `data.viewPosition` is already set.
480+
*
481+
* **Note:** these callbacks are called **very often**. For efficiency reasons, it is advised to use them only when position
482+
* mapping between given model and view elements is unsolvable using just elements mapping and default algorithm. Also,
483+
* the condition that checks if special case scenario happened should be as simple as possible.
484+
*
485+
* @event modelToViewPosition
486+
* @param {Object} data Data pipeline object that can store and pass data between callbacks. The callback should add
487+
* `viewPosition` value to that object with calculated {@link module:engine/view/position~Position view position}.
488+
* @param {module:engine/conversion/mapper~Mapper} data.mapper Mapper instance that fired the event.
489+
*/
448490

449-
/**
450-
* Fired for each model-to-view position mapping request. The purpose of this event is to enable custom model-to-view position
451-
* mapping. Callbacks added to this event take {@link module:engine/model/position~Position model position} and are expected to calculate
452-
* {@link module:engine/view/position~Position view position}. Calculated view position should be added as `viewPosition` value in
453-
* `data` object that is passed as one of parameters to the event callback.
454-
*
455-
* // Assume that "captionedImage" model element is converted to <img> and following <span> elements in view,
456-
* // and the model element is bound to <img> element. Force mapping model positions inside "captionedImage" to that <span> element.
457-
* mapper.on( 'modelToViewPosition', ( evt, data ) => {
458-
* const positionParent = modelPosition.parent;
459-
*
460-
* if ( positionParent.name == 'captionedImage' ) {
461-
* const viewImg = mapper.toViewElement( positionParent );
462-
* const viewCaption = viewImg.nextSibling; // The <span> element.
463-
*
464-
* data.viewPosition = new ViewPosition( viewCaption, modelPosition.offset );
465-
* evt.stop();
466-
* }
467-
* } );
468-
*
469-
* **Note:** these callbacks are called **very often**. For efficiency reasons, it is advised to use them only when position
470-
* mapping between given model and view elements is unsolvable using just elements mapping and default algorithm. Also,
471-
* the condition that checks if special case scenario happened should be as simple as possible.
472-
*
473-
* @event modelToViewPosition
474-
* @param {Object} data Data pipeline object that can store and pass data between callbacks. The callback should add
475-
* `viewPosition` value to that object with calculated {@link module:engine/view/position~Position view position}.
476-
* @param {module:engine/model/position~Position} data.modelPosition Model position to be mapped.
477-
* @param {module:engine/view/position~Position} data.viewPosition View position that is a result of mapping
478-
* `modelPosition` using `Mapper` default algorithm.
479-
* @param {module:engine/conversion/mapper~Mapper} data.mapper Mapper instance that fired the event.
480-
*/
491+
/**
492+
* Fired for each view-to-model position mapping request. See {@link module:engine/conversion/mapper~Mapper#event:modelToViewPosition}.
493+
*
494+
* // See example in `modelToViewPosition` event description.
495+
* // This custom mapping will map positions from <span> element next to <img> to the "captionedImage" element.
496+
* mapper.on( 'viewToModelPosition', ( evt, data ) => {
497+
* const positionParent = viewPosition.parent;
498+
*
499+
* if ( positionParent.hasClass( 'image-caption' ) ) {
500+
* const viewImg = positionParent.previousSibling;
501+
* const modelImg = data.mapper.toModelElement( viewImg );
502+
*
503+
* data.modelPosition = new ModelPosition( modelImg, viewPosition.offset );
504+
* evt.stop();
505+
* }
506+
* } );
507+
*
508+
* **Note:** default mapping callback is provided with `low` priority setting and does not cancel the event, so it is possible to attach
509+
* a custom callback after default callback and also use `data.modelPosition` calculated by default callback (for example to fix it).
510+
*
511+
* **Note:** default mapping callback will not fire if `data.modelPosition` is already set.
512+
*
513+
* **Note:** these callbacks are called **very often**. For efficiency reasons, it is advised to use them only when position
514+
* mapping between given model and view elements is unsolvable using just elements mapping and default algorithm. Also,
515+
* the condition that checks if special case scenario happened should be as simple as possible.
516+
*
517+
* @event viewToModelPosition
518+
* @param {Object} data Data pipeline object that can store and pass data between callbacks. The callback should add
519+
* `modelPosition` value to that object with calculated {@link module:engine/model/position~Position model position}.
520+
* @param {module:engine/conversion/mapper~Mapper} data.mapper Mapper instance that fired the event.
521+
*/
522+
}
481523

482-
/**
483-
* Fired for each view-to-model position mapping request. See {@link module:engine/conversion/mapper~Mapper#event:modelToViewPosition}.
484-
*
485-
* // See example in `modelToViewPosition` event description.
486-
* // This custom mapping will map positions from <span> element next to <img> to the "captionedImage" element.
487-
* mapper.on( 'viewToModelPosition', ( evt, data ) => {
488-
* const positionParent = viewPosition.parent;
489-
*
490-
* if ( positionParent.hasClass( 'image-caption' ) ) {
491-
* const viewImg = positionParent.previousSibling;
492-
* const modelImg = mapper.toModelElement( viewImg );
493-
*
494-
* data.modelPosition = new ModelPosition( modelImg, viewPosition.offset );
495-
* evt.stop();
496-
* }
497-
* } );
498-
*
499-
* @event viewToModelPosition
500-
* @param {Object} data Data pipeline object that can store and pass data between callbacks. The callback should add
501-
* `modelPosition` value to that object with calculated {@link module:engine/model/position~Position model position}.
502-
* @param {module:engine/view/position~Position} data.viewPosition View position to be mapped.
503-
* @param {module:engine/model/position~Position} data.modelPosition Model position that is a result of mapping
504-
* `viewPosition` using `Mapper` default algorithm.
505-
* @param {module:engine/conversion/mapper~Mapper} data.mapper Mapper instance that fired the event.
506-
*/
524+
mix( Mapper, EmitterMixin );

src/conversion/model-to-view-converters.js

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
* For licensing, see LICENSE.md.
44
*/
55

6-
import ModelRange from '../model/range';
7-
86
import ViewElement from '../view/element';
97
import ViewText from '../view/text';
8+
import ViewRange from '../view/range';
9+
import ViewTreeWalker from '../view/treewalker';
1010
import viewWriter from '../view/writer';
1111

1212
/**
@@ -436,9 +436,27 @@ export function remove() {
436436
return;
437437
}
438438

439-
const modelRange = ModelRange.createFromPositionAndShift( data.sourcePosition, data.item.offsetSize );
440-
const viewRange = conversionApi.mapper.toViewRange( modelRange );
439+
// We cannot map non-existing positions from model to view. Since a range was removed
440+
// from the model, we cannot recreate that range and map it to view, because
441+
// end of that range is incorrect.
442+
// Instead we will use `data.sourcePosition` as this is the last correct model position and
443+
// it is a position before the removed item. Then, we will calculate view range to remove "manually".
444+
const viewPosition = conversionApi.mapper.toViewPosition( data.sourcePosition );
445+
let viewRange;
446+
447+
if ( data.item.is( 'element' ) ) {
448+
// Note: in remove conversion we cannot use model-to-view element mapping because `data.item` may be
449+
// already mapped to another element (this happens when move change is converted).
450+
// In this case however, `viewPosition` is the position before view element that corresponds to removed model element.
451+
viewRange = ViewRange.createOn( viewPosition.nodeAfter );
452+
} else {
453+
// If removed item is a text node, we need to traverse view tree to find the view range to remove.
454+
// Range to remove will start `viewPosition` and should contain amount of characters equal to the amount of removed characters.
455+
const viewRangeEnd = _shiftViewPositionByCharacters( viewPosition, data.item.offsetSize );
456+
viewRange = new ViewRange( viewPosition, viewRangeEnd );
457+
}
441458

459+
// Trim the range to remove in case some UI elements are on the view range boundaries.
442460
viewWriter.remove( viewRange.getTrimmed() );
443461

444462
// Unbind this element only if it was moved to graveyard.
@@ -460,6 +478,26 @@ export function remove() {
460478
};
461479
}
462480

481+
// Helper function that shifts given view `position` in a way that returned position is after `howMany` characters compared
482+
// to the original `position`.
483+
// Because in view there might be view ui elements splitting text nodes, we cannot simply use `ViewPosition#getShiftedBy()`.
484+
function _shiftViewPositionByCharacters( position, howMany ) {
485+
// Create a walker that will walk the view tree starting from given position and walking characters one-by-one.
486+
const walker = new ViewTreeWalker( { startPosition: position, singleCharacters: true } );
487+
// We will count visited characters and return the position after `howMany` characters.
488+
let charactersFound = 0;
489+
490+
for ( let value of walker ) {
491+
if ( value.type == 'text' ) {
492+
charactersFound++;
493+
494+
if ( charactersFound == howMany ) {
495+
return walker.position;
496+
}
497+
}
498+
}
499+
}
500+
463501
/**
464502
* Function factory, creates a default model-to-view converter for removing {@link module:engine/view/uielement~UIElement ui element}
465503
* basing on marker remove change.

src/model/delta/insertdelta.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
*/
99

1010
import Delta from './delta';
11-
import DeltaFactory from './deltafactory';
1211
import RemoveDelta from './removedelta';
13-
import { register } from '../batch';
12+
import DeltaFactory from './deltafactory';
1413
import InsertOperation from '../operation/insertoperation';
14+
import { register } from '../batch';
15+
import { normalizeNodes } from './../writer';
1516

1617
import DocumentFragment from '../documentfragment';
1718
import Range from '../../model/range.js';
@@ -95,8 +96,15 @@ export default class InsertDelta extends Delta {
9596
* @param {module:engine/model/node~NodeSet} nodes The list of nodes to be inserted.
9697
*/
9798
register( 'insert', function( position, nodes ) {
99+
const normalizedNodes = normalizeNodes( nodes );
100+
101+
// If nothing is inserted do not create delta and operation.
102+
if ( normalizedNodes.length === 0 ) {
103+
return this;
104+
}
105+
98106
const delta = new InsertDelta();
99-
const insert = new InsertOperation( position, nodes, this.document.version );
107+
const insert = new InsertOperation( position, normalizedNodes, this.document.version );
100108

101109
this.addDelta( delta );
102110
delta.addOperation( insert );

0 commit comments

Comments
 (0)