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

Commit 2e7f75d

Browse files
authored
Merge pull request #845 from ckeditor/t/787
Feature: Introduced markers serialization. Closes #787. Closes #846. BREAKING CHANGES: `BuildModelConverter#fromMarkerCollapsed` is removed. Use `BuildModelConverter#fromMarker` instead. NOTE: `insertUIElement` model to view converter now supports collapsed and non-collapsed ranges.
2 parents efe3987 + e72ab66 commit 2e7f75d

15 files changed

+899
-174
lines changed

src/controller/datacontroller.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -224,10 +224,13 @@ export default class DataController {
224224
}
225225

226226
/**
227-
* Returns the content of the given {@link module:engine/view/element~Element view element} or
227+
* Returns wrapped by {module:engine/model/documentfragment~DocumentFragment} result of the given
228+
* {@link module:engine/view/element~Element view element} or
228229
* {@link module:engine/view/documentfragment~DocumentFragment view document fragment} converted by the
229-
* {@link #viewToModel view to model converters} to a
230-
* {@link module:engine/model/documentfragment~DocumentFragment model document fragment}.
230+
* {@link #viewToModel view to model converters}.
231+
*
232+
* When marker stamps were converted during conversion process then will be set as DocumentFragment
233+
* {@link module:engine/view/documentfragment~DocumentFragment#markers static markers map}.
231234
*
232235
* @param {module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment} viewElementOrFragment
233236
* Element or document fragment which content will be converted.
@@ -281,7 +284,7 @@ export default class DataController {
281284
* See {@link module:engine/controller/modifyselection~modifySelection}.
282285
*
283286
* @fires modifySelection
284-
* @param {module:engine/model/selection~Selection} The selection to modify.
287+
* @param {module:engine/model/selection~Selection} selection The selection to modify.
285288
* @param {Object} options See {@link module:engine/controller/modifyselection~modifySelection}'s options.
286289
*/
287290
modifySelection( selection, options ) {

src/conversion/buildmodelconverter.js

Lines changed: 78 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,7 @@ class ModelConverterBuilder {
152152
}
153153

154154
/**
155-
* Registers what type of non-collapsed marker should be converted. For collapsed markers conversion, see
156-
* {@link #fromCollapsedMarker}.
155+
* Registers what type of marker should be converted.
157156
*
158157
* @chainable
159158
* @param {String} markerName Name of marker to convert.
@@ -163,27 +162,7 @@ class ModelConverterBuilder {
163162
this._from = {
164163
type: 'marker',
165164
name: markerName,
166-
priority: null,
167-
collapsed: false
168-
};
169-
170-
return this;
171-
}
172-
173-
/**
174-
* Registers what type of collapsed marker should be converted. For non-collapsed markers conversion,
175-
* see {@link #fromMarker}.
176-
*
177-
* @chainable
178-
* @param {String} markerName Name of marker to convert.
179-
* @returns {module:engine/conversion/buildmodelconverter~ModelConverterBuilder}
180-
*/
181-
fromCollapsedMarker( markerName ) {
182-
this._from = {
183-
type: 'marker',
184-
name: markerName,
185-
priority: null,
186-
collapsed: true
165+
priority: null
187166
};
188167

189168
return this;
@@ -265,22 +244,76 @@ class ModelConverterBuilder {
265244

266245
dispatcher.on( 'selectionAttribute:' + this._from.key, convertSelectionAttribute( element ), { priority } );
267246
} else {
268-
if ( this._from.collapsed ) {
269-
// From collapsed marker to view element -> insertUIElement, removeUIElement.
270-
element = typeof element == 'string' ? new ViewUIElement( element ) : element;
247+
element = typeof element == 'string' ? new ViewAttributeElement( element ) : element;
271248

272-
dispatcher.on( 'addMarker:' + this._from.name, insertUIElement( element ), { priority } );
273-
dispatcher.on( 'removeMarker:' + this._from.name, removeUIElement( element ), { priority } );
274-
} else {
275-
// From non-collapsed marker to view element -> wrapRange and unwrapRange.
276-
element = typeof element == 'string' ? new ViewAttributeElement( element ) : element;
249+
dispatcher.on( 'addMarker:' + this._from.name, wrapRange( element ), { priority } );
250+
dispatcher.on( 'removeMarker:' + this._from.name, unwrapRange( element ), { priority } );
277251

278-
dispatcher.on( 'addMarker:' + this._from.name, wrapRange( element ), { priority } );
279-
dispatcher.on( 'removeMarker:' + this._from.name, unwrapRange( element ), { priority } );
252+
dispatcher.on( 'selectionMarker:' + this._from.name, convertSelectionMarker( element ), { priority } );
253+
}
254+
}
255+
}
280256

281-
dispatcher.on( 'selectionMarker:' + this._from.name, convertSelectionMarker( element ), { priority } );
282-
}
257+
/**
258+
* Registers what view stamp will be created by converter to mark marker range bounds. Separate elements will be
259+
* created at the beginning and at the end of the range. If range is collapsed then only one element will be created.
260+
*
261+
* Method accepts various ways of providing how the view element will be created. You can pass view element name as
262+
* `string`, view element instance which will be cloned and used, or creator function which returns view element that
263+
* will be used. Keep in mind that when you provide view element instance or creator function, it has to be/return a
264+
* proper type of view element: {@link module:engine/view/uielement~UIElement UIElement}.
265+
*
266+
* buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toStamp( 'span' );
267+
*
268+
* buildModelConverter().for( dispatcher )
269+
* .fromMarker( 'search' )
270+
* .toStamp( new UIElement( 'span', { 'data-name': 'search' } ) );
271+
*
272+
* buildModelConverter().for( dispatcher )
273+
* .fromMarker( 'search' )
274+
* .toStamp( ( data ) => new UIElement( 'span', { 'data-name': data.marker.getName() ) );
275+
*
276+
* Creator function provides additional `data.isOpening` parameter which defined if currently converted element is
277+
* a beginning or end of the marker range. This makes possible to create different opening and closing stamp.
278+
*
279+
* buildModelConverter().for( dispatcher )
280+
* .fromMarker( 'search' )
281+
* .toStamp( ( data ) => {
282+
* if ( data.isOpening ) {
283+
* return new UIElement( 'span', { 'data-name': data.marker.getName(), 'data-start': true ) );
284+
* }
285+
*
286+
* return new UIElement( 'span', { 'data-name': data.marker.getName(), 'data-end': true ) );
287+
* }
288+
*
289+
* Creator function provides
290+
* {@link module:engine/conversion/buildmodelconverter~ModelConverterBuilder#StampCreatorData} parameters.
291+
*
292+
* See how markers {module:engine/model/buildviewconverter~ViewConverterBuilder#toMarker view -> model serialization}
293+
* works to find out what view element format is the best for you.
294+
*
295+
* @param {String|module:engine/view/element~UIElement|Function} element UIElement created by converter or
296+
* a function that returns view element.
297+
*/
298+
toStamp( element ) {
299+
for ( let dispatcher of this._dispatchers ) {
300+
if ( this._from.type != 'marker' ) {
301+
/**
302+
* To-stamp conversion is supported only for model markers.
303+
*
304+
* @error build-model-converter-element-to-stamp
305+
*/
306+
throw new CKEditorError(
307+
'build-model-converter-non-marker-to-stamp: To-stamp conversion is supported only from model markers.'
308+
);
283309
}
310+
311+
const priority = this._from.priority === null ? 'normal' : this._from.priority;
312+
313+
element = typeof element == 'string' ? new ViewUIElement( element ) : element;
314+
315+
dispatcher.on( 'addMarker:' + this._from.name, insertUIElement( element ), { priority } );
316+
dispatcher.on( 'removeMarker:' + this._from.name, removeUIElement( element ), { priority } );
284317
}
285318
}
286319

@@ -321,7 +354,6 @@ class ModelConverterBuilder {
321354
* To-attribute conversion is supported only for model attributes.
322355
*
323356
* @error build-model-converter-element-to-attribute
324-
* @param {module:engine/model/range~Range} range
325357
*/
326358
throw new CKEditorError( 'build-model-converter-non-attribute-to-attribute: ' +
327359
'To-attribute conversion is supported only from model attributes.' );
@@ -370,3 +402,13 @@ class ModelConverterBuilder {
370402
export default function buildModelConverter() {
371403
return new ModelConverterBuilder();
372404
}
405+
406+
/**
407+
* @typedef {StampCreatorData} {module:engine/conversion/buildmodelconverter~ModelConverterBuilder#StampCreatorData}
408+
* @param {Object} data Additional information about the change.
409+
* @param {String} data.name Marker name.
410+
* @param {module:engine/model/range~Range} data.range Marker range.
411+
* @param {Boolean} data.isOpening Defines if currently converted element is a beginning or end of the marker range.
412+
* @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
413+
* @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
414+
*/

src/conversion/buildviewconverter.js

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import Matcher from '../view/matcher';
1111
import ModelElement from '../model/element';
1212
import ModelPosition from '../model/position';
1313
import modelWriter from '../model/writer';
14+
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
1415
import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
1516

1617
/**
@@ -254,7 +255,7 @@ class ViewConverterBuilder {
254255
* @param {String|Function} element Model element name or model element creator function.
255256
*/
256257
toElement( element ) {
257-
const eventCallbackGen = function( from ) {
258+
function eventCallbackGen( from ) {
258259
return ( evt, data, consumable, conversionApi ) => {
259260
// There is one callback for all patterns in the matcher.
260261
// This will be usually just one pattern but we support matchers with many patterns too.
@@ -301,7 +302,7 @@ class ViewConverterBuilder {
301302
break;
302303
}
303304
};
304-
};
305+
}
305306

306307
this._setCallback( eventCallbackGen, 'normal' );
307308
}
@@ -322,7 +323,7 @@ class ViewConverterBuilder {
322323
* @param {String} [value] Attribute value. Required if `keyOrCreator` is a `string`. Ignored otherwise.
323324
*/
324325
toAttribute( keyOrCreator, value ) {
325-
const eventCallbackGen = function( from ) {
326+
function eventCallbackGen( from ) {
326327
return ( evt, data, consumable, conversionApi ) => {
327328
// There is one callback for all patterns in the matcher.
328329
// This will be usually just one pattern but we support matchers with many patterns too.
@@ -356,11 +357,91 @@ class ViewConverterBuilder {
356357
break;
357358
}
358359
};
359-
};
360+
}
360361

361362
this._setCallback( eventCallbackGen, 'low' );
362363
}
363364

365+
/**
366+
* Registers how model element marking marker range will be created by converter.
367+
*
368+
* Created element has to match the following pattern:
369+
*
370+
* { name: '$marker', attribute: { data-name: /^\w/ } }
371+
*
372+
* There are two ways of creating this element:
373+
*
374+
* 1. Makes sure that converted view element will have property `data-name` then converter will
375+
* automatically take this property value. In this case there is no need to provide creator function.
376+
* For the following view:
377+
*
378+
* <marker data-name="search"></marker>foo<marker data-name="search"></marker>
379+
*
380+
* converter should look like this:
381+
*
382+
* buildViewConverter().for( dispatcher ).fromElement( 'marker' ).toMarker();
383+
*
384+
* 2. Creates element by creator:
385+
*
386+
* For the following view:
387+
*
388+
* <span foo="search"></span>foo<span foo="search"></span>
389+
*
390+
* converter should look like this:
391+
*
392+
* buildViewConverter().for( dispatcher ).from( { name: 'span', { attribute: foo: /^\w/ } } ).toMarker( ( data ) => {
393+
* return new Element( '$marker', { 'data-name': data.getAttribute( 'foo' ) } );
394+
* } );
395+
*
396+
* @param {Function} [creator] Creator function.
397+
*/
398+
toMarker( creator ) {
399+
function eventCallbackGen( from ) {
400+
return ( evt, data, consumable ) => {
401+
// There is one callback for all patterns in the matcher.
402+
// This will be usually just one pattern but we support matchers with many patterns too.
403+
const matchAll = from.matcher.matchAll( data.input );
404+
405+
// If there is no match, this callback should not do anything.
406+
if ( !matchAll ) {
407+
return;
408+
}
409+
410+
let modelElement;
411+
412+
// When creator is provided then create model element basing on creator function.
413+
if ( creator instanceof Function ) {
414+
modelElement = creator( data.input );
415+
// When there is no creator then create model element basing on data from view element.
416+
} else {
417+
modelElement = new ModelElement( '$marker', { 'data-name': data.input.getAttribute( 'data-name' ) } );
418+
}
419+
420+
// Check if model element is correct (has proper name and property).
421+
if ( modelElement.name != '$marker' || typeof modelElement.getAttribute( 'data-name' ) != 'string' ) {
422+
throw new CKEditorError(
423+
'build-view-converter-invalid-marker: Invalid model element to mark marker range.'
424+
);
425+
}
426+
427+
// Now, for every match between matcher and actual element, we will try to consume the match.
428+
for ( const match of matchAll ) {
429+
// Try to consume appropriate values from consumable values list.
430+
if ( !consumable.consume( data.input, from.consume || match.match ) ) {
431+
continue;
432+
}
433+
434+
data.output = modelElement;
435+
436+
// Prevent multiple conversion if there are other correct matches.
437+
break;
438+
}
439+
};
440+
}
441+
442+
this._setCallback( eventCallbackGen, 'normal' );
443+
}
444+
364445
/**
365446
* Helper function that uses given callback generator to created callback function and sets it on registered dispatchers.
366447
*

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

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ export function insertText() {
9191
/**
9292
* Function factory, creates a converter that converts marker adding change to the view ui element.
9393
* The view ui element that will be added to the view depends on passed parameter. See {@link ~insertElement}.
94+
* In a case of collapsed range element will not wrap range but separate elements will be placed at the beginning
95+
* and at the end of the range.
9496
*
9597
* **Note:** unlike {@link ~insertElement}, the converter does not bind view element to model, because this converter
9698
* uses marker as "model source of data". This means that view ui element does not have corresponding model element.
@@ -101,21 +103,34 @@ export function insertText() {
101103
*/
102104
export function insertUIElement( elementCreator ) {
103105
return ( evt, data, consumable, conversionApi ) => {
104-
const viewElement = ( elementCreator instanceof ViewElement ) ?
105-
elementCreator.clone( true ) :
106-
elementCreator( data, consumable, conversionApi );
106+
let viewStartElement, viewEndElement;
107107

108-
if ( !viewElement ) {
108+
if ( elementCreator instanceof ViewElement ) {
109+
viewStartElement = elementCreator.clone( true );
110+
viewEndElement = elementCreator.clone( true );
111+
} else {
112+
data.isOpening = true;
113+
viewStartElement = elementCreator( data, consumable, conversionApi );
114+
115+
data.isOpening = false;
116+
viewEndElement = elementCreator( data, consumable, conversionApi );
117+
}
118+
119+
if ( !viewStartElement || !viewEndElement ) {
109120
return;
110121
}
111122

112123
if ( !consumable.consume( data.range, 'addMarker' ) ) {
113124
return;
114125
}
115126

116-
const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
127+
const mapper = conversionApi.mapper;
117128

118-
viewWriter.insert( viewPosition, viewElement );
129+
viewWriter.insert( mapper.toViewPosition( data.range.start ), viewStartElement );
130+
131+
if ( !data.range.isCollapsed ) {
132+
viewWriter.insert( mapper.toViewPosition( data.range.end ), viewEndElement );
133+
}
119134
};
120135
}
121136

@@ -439,11 +454,20 @@ export function remove() {
439454
*/
440455
export function removeUIElement( elementCreator ) {
441456
return ( evt, data, consumable, conversionApi ) => {
442-
const viewElement = ( elementCreator instanceof ViewElement ) ?
443-
elementCreator.clone( true ) :
444-
elementCreator( data, consumable, conversionApi );
457+
let viewStartElement, viewEndElement;
445458

446-
if ( !viewElement ) {
459+
if ( elementCreator instanceof ViewElement ) {
460+
viewStartElement = elementCreator.clone( true );
461+
viewEndElement = elementCreator.clone( true );
462+
} else {
463+
data.isOpening = true;
464+
viewStartElement = elementCreator( data, consumable, conversionApi );
465+
466+
data.isOpening = false;
467+
viewEndElement = elementCreator( data, consumable, conversionApi );
468+
}
469+
470+
if ( !viewStartElement || !viewEndElement ) {
447471
return;
448472
}
449473

@@ -453,7 +477,13 @@ export function removeUIElement( elementCreator ) {
453477

454478
const viewRange = conversionApi.mapper.toViewRange( data.range );
455479

456-
viewWriter.clear( viewRange.getEnlarged(), viewElement );
480+
// First remove closing element.
481+
viewWriter.clear( viewRange.getEnlarged(), viewEndElement );
482+
483+
// If closing and opening elements are not the same then remove opening element.
484+
if ( !viewStartElement.isSimilar( viewEndElement ) ) {
485+
viewWriter.clear( viewRange.getEnlarged(), viewStartElement );
486+
}
457487
};
458488
}
459489

0 commit comments

Comments
 (0)