/** * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ /** * @module engine/conversion/downcastdispatcher */ import Consumable from './modelconsumable'; import Range from '../model/range'; import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; /** * Downcast dispatcher is a central point of downcasting (conversion from the model to the view), which is a process of reacting to changes * in the model and firing a set of events. Callbacks listening to these events are called converters. The * converters' role is to convert the model changes to changes in view (for example, adding view nodes or * changing attributes on view elements). * * During the conversion process, downcast dispatcher fires events basing on the state of the model and prepares * data for these events. It is important to understand that the events are connected with the changes done on the model, * for example: "a node has been inserted" or "an attribute has changed". This is in contrary to upcasting (a view-to-model conversion) * where you convert the view state (view nodes) to a model tree. * * The events are prepared basing on a diff created by {@link module:engine/model/differ~Differ Differ}, which buffers them * and then passes to the downcast dispatcher as a diff between the old model state and the new model state. * * Note that because the changes are converted, there is a need to have a mapping between the model structure and the view structure. * To map positions and elements during the downcast (a model-to-view conversion), use {@link module:engine/conversion/mapper~Mapper}. * * Downcast dispatcher fires the following events for model tree changes: * * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert `insert`} – * If a range of nodes was inserted to the model tree. * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:remove `remove`} – * If a range of nodes was removed from the model tree. * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute `attribute`} – * If an attribute was added, changed or removed from a model node. * * For {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert `insert`} * and {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute `attribute`}, * downcast dispatcher generates {@link module:engine/conversion/modelconsumable~ModelConsumable consumables}. * These are used to have control over which changes have already been consumed. It is useful when some converters * overwrite others or convert multiple changes (for example, it converts an insertion of an element and also converts that * element's attributes during the insertion). * * Additionally, downcast dispatcher fires events for {@link module:engine/model/markercollection~Marker marker} changes: * * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker} – If a marker was added. * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:removeMarker} – If a marker was removed. * * Note that changing a marker is done through removing the marker from the old range and adding it on the new range, * so both events are fired. * * Finally, downcast dispatcher also handles firing events for the {@link module:engine/model/selection model selection} * conversion: * * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:selection} * – Converts the selection from the model to the view. * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute} * – Fired for every selection attribute. * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker} * – Fired for every marker that contains a selection. * * Unlike model tree and markers, events for selection are not fired for changes but for selection state. * * When providing custom listeners for downcast dispatcher, remember to check whether a given change has not been * {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed} yet. * * When providing custom listeners for downcast dispatcher, keep in mind that any callback that has * {@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). * * When providing custom listeners for downcast dispatcher, remember to use the provided * {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer} to apply changes to the view document. * * An example of a custom converter for the downcast dispatcher: * * // You will convert inserting a "paragraph" model element into the model. * downcastDispatcher.on( 'insert:paragraph', ( evt, data, conversionApi ) => { * // Remember to check whether the change has not been consumed yet and consume it. * if ( conversionApi.consumable.consume( data.item, 'insert' ) ) { * return; * } * * // Translate the position in the model to a position in the view. * const viewPosition = conversionApi.mapper.toViewPosition( data.range.start ); * * // Create a
element that will be inserted into the view at the `viewPosition`.
* const viewElement = conversionApi.writer.createContainerElement( 'p' );
*
* // Bind the newly created view element to the model element so positions will map accordingly in the future.
* conversionApi.mapper.bindElements( data.item, viewElement );
*
* // Add the newly created view element to the view.
* conversionApi.writer.insert( viewPosition, viewElement );
*
* // Remember to stop the event propagation.
* evt.stop();
* } );
*/
export default class DowncastDispatcher {
/**
* Creates a downcast dispatcher instance.
*
* @see module:engine/conversion/downcastdispatcher~DowncastConversionApi
* @param {Object} conversionApi Additional properties for an interface that will be passed to events fired
* by the downcast dispatcher.
*/
constructor( conversionApi ) {
/**
* An interface passed by the dispatcher to the event callbacks.
*
* @member {module:engine/conversion/downcastdispatcher~DowncastConversionApi}
*/
this.conversionApi = Object.assign( { dispatcher: this }, conversionApi );
this._map = new Map();
}
/**
* Takes a {@link module:engine/model/differ~Differ model differ} object with buffered changes and fires conversion basing on it.
*
* @param {module:engine/model/differ~Differ} differ The differ object with buffered changes.
* @param {module:engine/model/markercollection~MarkerCollection} markers Markers connected with the converted model.
* @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer that should be used to modify the view document.
*/
convertChanges( differ, markers, writer ) {
// Before the view is updated, remove markers which have changed.
for ( const change of differ.getMarkersToRemove() ) {
this.convertMarkerRemove( change.name, change.range, writer );
}
const changes = differ.getChanges();
if ( changes.length ) {
// @if CK_DEBUG // console.log( `convertChanges() size: ${ changes.length }` );
}
// Convert changes that happened on model tree.
for ( const entry of changes ) {
// @if CK_DEBUG // console.log( `differ: ${ entry.type }` );
if ( entry.type == 'insert' ) {
this.convertInsert( Range._createFromPositionAndShift( entry.position, entry.length ), writer );
} else if ( entry.type == 'remove' ) {
this.convertRemove( entry.position, entry.length, entry.name, writer );
} else if ( entry.type == 'refresh' ) {
// @if CK_DEBUG console.warn( 'convert refresh' );
this.convertRefresh( Range._createFromPositionAndShift( entry.position, entry.length ), writer );
} else if ( entry.type == 'attribute' ) {
this.convertAttribute( entry.range, entry.attributeKey, entry.attributeOldValue, entry.attributeNewValue, writer );
} else {
// todo warning
}
}
for ( const markerName of this.conversionApi.mapper.flushUnboundMarkerNames() ) {
const markerRange = markers.get( markerName ).getRange();
this.convertMarkerRemove( markerName, markerRange, writer );
this.convertMarkerAdd( markerName, markerRange, writer );
}
// After the view is updated, convert markers which have changed.
for ( const change of differ.getMarkersToAdd() ) {
this.convertMarkerAdd( change.name, change.range, writer );
}
}
mapRefreshEvents( modelName, events = [] ) {
for ( const eventName of events ) {
this._map.set( eventName, modelName );
}
}
pocCheckChangesForRefresh( differ ) {
const changes = differ.getChanges();
const found = [ ...changes ]
.filter( ( { type } ) => type === 'attribute' || type === 'insert' || type === 'remove' )
.map( entry => {
const { range, position, type } = entry;
const element = range && range.start.nodeAfter || position && position.parent;
let eventName;
if ( type === 'attribute' ) {
// TODO: enhance event name retrieval.
eventName = `attribute:${ entry.attributeKey }:${ element && element.name }`;
} else {
eventName = `${ type }:${ entry.name }`;
}
if ( this._map.has( eventName ) ) {
const expectedElement = this._map.get( eventName );
return element.is( 'element', expectedElement ) ? element : element.findAncestor( expectedElement );
}
} )
.filter( element => !!element );
const elementsToRefresh = new Set( found );
[ ...elementsToRefresh.values() ].forEach( box => differ._pocRefreshItem( box ) );
}
/**
* Starts a conversion of a range 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 attribute
* @param {module:engine/model/range~Range} range The inserted range.
* @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer that should be used to modify the view document.
*/
convertInsert( range, writer ) {
this.conversionApi.writer = writer;
// Create a list of things that can be consumed, consisting of nodes and their attributes.
this.conversionApi.consumable = this._createInsertConsumable( range );
// Fire a separate insert event for each node and text fragment contained in the range.
for ( const value of range ) {
const item = value.item;
const itemRange = Range._createFromPositionAndShift( value.previousPosition, value.length );
const data = {
item,
range: itemRange
};
this._testAndFire( 'insert', data );
// Fire a separate addAttribute event for each attribute that was set on inserted items.
// This is important because most attributes converters will listen only to add/change/removeAttribute events.
// If we would not add this part, attributes on inserted nodes would not be converted.
for ( const key of item.getAttributeKeys() ) {
data.attributeKey = key;
data.attributeOldValue = null;
data.attributeNewValue = item.getAttribute( key );
this._testAndFire( `attribute:${ key }`, data );
}
}
this._clearConversionApi();
}
/**
* Fires conversion of a single node removal. Fires {@link #event:remove remove event} with provided data.
*
* @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.
* @param {module:engine/view/downcastwriter~DowncastWriter} writer View writer that should be used to modify view document.
*/
convertRemove( position, length, name, writer ) {
this.conversionApi.writer = writer;
this.fire( 'remove:' + name, { position, length }, this.conversionApi );
this._clearConversionApi();
}
/**
* Starts conversion of attribute change on given `range`.
*
* For each node in the given `range`, {@link #event:attribute attribute event} is fired with the passed data.
*
* @fires attribute
* @param {module:engine/model/range~Range} range Changed range.
* @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.
* @param {module:engine/view/downcastwriter~DowncastWriter} writer View writer that should be used to modify view document.
*/
convertAttribute( range, key, oldValue, newValue, writer ) {
this.conversionApi.writer = writer;
// Create a list with attributes to consume.
this.conversionApi.consumable = this._createConsumableForRange( range, `attribute:${ key }` );
// Create a separate attribute event for each node in the range.
for ( const value of range ) {
const item = value.item;
const itemRange = Range._createFromPositionAndShift( value.previousPosition, value.length );
const data = {
item,
range: itemRange,
attributeKey: key,
attributeOldValue: oldValue,
attributeNewValue: newValue
};
this._testAndFire( `attribute:${ key }`, data );
}
this._clearConversionApi();
}
convertRefresh( range, writer ) {
this.conversionApi.writer = writer;
// Create a list of things that can be consumed, consisting of nodes and their attributes.
this.conversionApi.consumable = this._createInsertConsumable( range );
for ( const value of range ) {
const item = value.item;
const itemRange = Range._createFromPositionAndShift( value.previousPosition, value.length );
const data = {
item,
range: itemRange,
isRefresh: true
};
this._testAndFire( 'insert', data );
}
this._clearConversionApi();
}
/**
* Starts model selection conversion.
*
* Fires events for given {@link module:engine/model/selection~Selection selection} to start selection conversion.
*
* @fires selection
* @fires addMarker
* @fires attribute
* @param {module:engine/model/selection~Selection} selection Selection to convert.
* @param {module:engine/model/markercollection~MarkerCollection} markers Markers connected with converted model.
* @param {module:engine/view/downcastwriter~DowncastWriter} writer View writer that should be used to modify view document.
*/
convertSelection( selection, markers, writer ) {
const markersAtSelection = Array.from( markers.getMarkersAtPosition( selection.getFirstPosition() ) );
this.conversionApi.writer = writer;
this.conversionApi.consumable = this._createSelectionConsumable( selection, markersAtSelection );
this.fire( 'selection', { selection }, this.conversionApi );
if ( !selection.isCollapsed ) {
return;
}
for ( const marker of markersAtSelection ) {
const markerRange = marker.getRange();
if ( !shouldMarkerChangeBeConverted( selection.getFirstPosition(), marker, this.conversionApi.mapper ) ) {
continue;
}
const data = {
item: selection,
markerName: marker.name,
markerRange
};
if ( this.conversionApi.consumable.test( selection, 'addMarker:' + marker.name ) ) {
this.fire( 'addMarker:' + marker.name, data, this.conversionApi );
}
}
for ( const key of selection.getAttributeKeys() ) {
const data = {
item: selection,
range: selection.getFirstRange(),
attributeKey: key,
attributeOldValue: null,
attributeNewValue: selection.getAttribute( key )
};
// Do not fire event if the attribute has been consumed.
if ( this.conversionApi.consumable.test( selection, 'attribute:' + data.attributeKey ) ) {
this.fire( 'attribute:' + data.attributeKey + ':$text', data, this.conversionApi );
}
}
this._clearConversionApi();
}
/**
* 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
* @param {String} markerName Marker name.
* @param {module:engine/model/range~Range} markerRange Marker range.
* @param {module:engine/view/downcastwriter~DowncastWriter} writer View writer that should be used to modify view document.
*/
convertMarkerAdd( markerName, markerRange, writer ) {
// 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.conversionApi.writer = writer;
// In markers' case, event name == consumable name.
const eventName = 'addMarker:' + markerName;
//
// First, fire an event for the whole marker.
//
const consumable = new Consumable();
consumable.add( markerRange, eventName );
this.conversionApi.consumable = consumable;
this.fire( eventName, { markerName, markerRange }, this.conversionApi );
//
// Do not fire events for each item inside the range if the range got consumed.
//
if ( !consumable.test( markerRange, eventName ) ) {
return;
}
//
// Then, fire an event for each item inside the marker range.
//
this.conversionApi.consumable = this._createConsumableForRange( markerRange, eventName );
for ( const item of markerRange.getItems() ) {
// Do not fire event for already consumed items.
if ( !this.conversionApi.consumable.test( item, eventName ) ) {
continue;
}
const data = { item, range: Range._createOn( item ), markerName, markerRange };
this.fire( eventName, data, this.conversionApi );
}
this._clearConversionApi();
}
/**
* 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.
* @param {module:engine/view/downcastwriter~DowncastWriter} writer View writer that should be used to modify view document.
*/
convertMarkerRemove( markerName, markerRange, writer ) {
// 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.conversionApi.writer = writer;
this.fire( 'removeMarker:' + markerName, { markerName, markerRange }, this.conversionApi );
this._clearConversionApi();
}
/**
* 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.
* @returns {module:engine/conversion/modelconsumable~ModelConsumable} Values to consume.
*/
_createInsertConsumable( range ) {
const consumable = new Consumable();
for ( const value of range ) {
const item = value.item;
consumable.add( item, 'insert' );
for ( const key of item.getAttributeKeys() ) {
consumable.add( item, 'attribute:' + key );
}
}
return consumable;
}
/**
* Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with values to consume for given range.
*
* @private
* @param {module:engine/model/range~Range} range Affected range.
* @param {String} type Consumable type.
* @returns {module:engine/conversion/modelconsumable~ModelConsumable} Values to consume.
*/
_createConsumableForRange( range, type ) {
const consumable = new Consumable();
for ( const item of range.getItems() ) {
consumable.add( item, type );
}
return consumable;
}
/**
* Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with selection consumable values.
*
* @private
* @param {module:engine/model/selection~Selection} selection Selection to create consumable from.
* @param {Iterable.