UNPKG

@ckeditor/ckeditor5-engine

Version:

The editing engine of CKEditor 5 – the best browser-based rich text editor.

986 lines • 53 kB
/** * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ /** * @module engine/conversion/mapper */ import { ModelPosition } from '../model/position.js'; import { ModelRange } from '../model/range.js'; import { ViewPosition } from '../view/position.js'; import { ViewRange } from '../view/range.js'; import { CKEditorError, EmitterMixin } from '@ckeditor/ckeditor5-utils'; /** * Maps elements, positions and markers between the {@link module:engine/view/document~ViewDocument view} and * the {@link module:engine/model/model model}. * * The instance of the Mapper used for the editing pipeline is available in * {@link module:engine/controller/editingcontroller~EditingController#mapper `editor.editing.mapper`}. * * Mapper uses bound elements to find corresponding elements and positions, so, to get proper results, * all model elements should be {@link module:engine/conversion/mapper~Mapper#bindElements bound}. * * To map the complex model to/from view relations, you may provide custom callbacks for the * {@link module:engine/conversion/mapper~Mapper#event:modelToViewPosition modelToViewPosition event} and * {@link module:engine/conversion/mapper~Mapper#event:viewToModelPosition viewToModelPosition event} that are fired whenever * a position mapping request occurs. * Those events are fired by the {@link module:engine/conversion/mapper~Mapper#toViewPosition toViewPosition} * and {@link module:engine/conversion/mapper~Mapper#toModelPosition toModelPosition} methods. `Mapper` adds its own default callbacks * with `'lowest'` priority. To override default `Mapper` mapping, add custom callback with higher priority and * stop the event. */ export class Mapper extends /* #__PURE__ */ EmitterMixin() { /** * Model element to view element mapping. */ _modelToViewMapping = new WeakMap(); /** * View element to model element mapping. */ _viewToModelMapping = new WeakMap(); /** * A map containing callbacks between view element names and functions evaluating length of view elements * in model. */ _viewToModelLengthCallbacks = new Map(); /** * Model marker name to view elements mapping. * * Keys are `String`s while values are `Set`s with {@link module:engine/view/element~ViewElement view elements}. * One marker (name) can be mapped to multiple elements. */ _markerNameToElements = new Map(); /** * View element to model marker names mapping. * * This is reverse to {@link ~Mapper#_markerNameToElements} map. */ _elementToMarkerNames = new Map(); /** * The map of removed view elements with their current root (used for deferred unbinding). */ _deferredBindingRemovals = new Map(); /** * Stores marker names of markers which have changed due to unbinding a view element (so it is assumed that the view element * has been removed, moved or renamed). */ _unboundMarkerNames = new Set(); /** * Manages dynamic cache for the `Mapper` to improve the performance. */ _cache = new MapperCache(); /** * Creates an instance of the mapper. */ constructor() { super(); // Default mapper algorithm for mapping model position to view position. this.on('modelToViewPosition', (evt, data) => { if (data.viewPosition) { return; } const viewContainer = this._modelToViewMapping.get(data.modelPosition.parent); if (!viewContainer) { /** * A model position could not be mapped to the view because the parent of the model position * does not have a mapped view element (might have not been converted yet or it has no converter). * * Make sure that the model element is correctly converted to the view. * * @error mapping-model-position-view-parent-not-found */ throw new CKEditorError('mapping-model-position-view-parent-not-found', this, { modelPosition: data.modelPosition }); } data.viewPosition = this.findPositionIn(viewContainer, data.modelPosition.offset); }, { priority: 'low' }); // Default mapper algorithm for mapping view position to model position. this.on('viewToModelPosition', (evt, data) => { if (data.modelPosition) { return; } const viewBlock = this.findMappedViewAncestor(data.viewPosition); const modelParent = this._viewToModelMapping.get(viewBlock); const modelOffset = this._toModelOffset(data.viewPosition.parent, data.viewPosition.offset, viewBlock); data.modelPosition = ModelPosition._createAt(modelParent, modelOffset); }, { priority: 'low' }); } /** * Marks model and view elements as corresponding. Corresponding elements can be retrieved by using * the {@link module:engine/conversion/mapper~Mapper#toModelElement toModelElement} and * {@link module:engine/conversion/mapper~Mapper#toViewElement toViewElement} methods. * The information that elements are bound is also used to translate positions. * * @param modelElement Model element. * @param viewElement View element. */ bindElements(modelElement, viewElement) { this._modelToViewMapping.set(modelElement, viewElement); this._viewToModelMapping.set(viewElement, modelElement); } /** * Unbinds the given {@link module:engine/view/element~ViewElement 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 the passed `viewElement`. * * This behavior allows 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 viewElement View element to unbind. * @param options The options object. * @param options.defer Controls whether the binding should be removed immediately or deferred until a * {@link #flushDeferredBindings `flushDeferredBindings()`} call. */ unbindViewElement(viewElement, options = {}) { const modelElement = this.toModelElement(viewElement); if (this._elementToMarkerNames.has(viewElement)) { for (const markerName of this._elementToMarkerNames.get(viewElement)) { this._unboundMarkerNames.add(markerName); } } if (options.defer) { this._deferredBindingRemovals.set(viewElement, viewElement.root); } else { const wasFound = this._viewToModelMapping.delete(viewElement); if (wasFound) { // Stop tracking after the element is no longer mapped. We want to track all mapped elements and only mapped elements. this._cache.stopTracking(viewElement); } if (this._modelToViewMapping.get(modelElement) == viewElement) { this._modelToViewMapping.delete(modelElement); } } } /** * Unbinds the given {@link module:engine/model/element~ModelElement model element} from the map. * * **Note:** the model-to-view binding will be removed, if it existed. However, the corresponding view-to-model binding * will be removed only if the view element is still bound to the 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 modelElement Model element to unbind. */ unbindModelElement(modelElement) { const viewElement = this.toViewElement(modelElement); this._modelToViewMapping.delete(modelElement); if (this._viewToModelMapping.get(viewElement) == modelElement) { const wasFound = this._viewToModelMapping.delete(viewElement); if (wasFound) { // Stop tracking after the element is no longer mapped. We want to track all mapped elements and only mapped elements. this._cache.stopTracking(viewElement); } } } /** * Binds the given marker name with the given {@link module:engine/view/element~ViewElement view element}. The element * will be added to the current set of elements bound with the given marker name. * * @param element Element to bind. * @param name Marker name. */ bindElementToMarker(element, name) { const elements = this._markerNameToElements.get(name) || new Set(); elements.add(element); const names = this._elementToMarkerNames.get(element) || new Set(); names.add(name); this._markerNameToElements.set(name, elements); this._elementToMarkerNames.set(element, names); } /** * Unbinds an element from given marker name. * * @param element Element to unbind. * @param name Marker name. */ unbindElementFromMarkerName(element, name) { const nameToElements = this._markerNameToElements.get(name); if (nameToElements) { nameToElements.delete(element); if (nameToElements.size == 0) { this._markerNameToElements.delete(name); } } const elementToNames = this._elementToMarkerNames.get(element); if (elementToNames) { elementToNames.delete(name); if (elementToNames.size == 0) { this._elementToMarkerNames.delete(element); } } } /** * Returns all marker names of markers which have changed due to unbinding a view element (so it is assumed that the view element * has been removed, moved or renamed) since the last flush. After returning, the marker names list is cleared. */ flushUnboundMarkerNames() { const markerNames = Array.from(this._unboundMarkerNames); this._unboundMarkerNames.clear(); return markerNames; } /** * Unbinds all deferred binding removals of view elements that in the meantime were not re-attached to some root or document fragment. * * See: {@link #unbindViewElement `unbindViewElement()`}. */ flushDeferredBindings() { for (const [viewElement, root] of this._deferredBindingRemovals) { // Unbind it only if it wasn't re-attached to some root or document fragment. if (viewElement.root == root) { this.unbindViewElement(viewElement); } } this._deferredBindingRemovals = new Map(); } /** * Removes all model to view and view to model bindings. */ clearBindings() { this._modelToViewMapping = new WeakMap(); this._viewToModelMapping = new WeakMap(); this._markerNameToElements = new Map(); this._elementToMarkerNames = new Map(); this._unboundMarkerNames = new Set(); this._deferredBindingRemovals = new Map(); } toModelElement(viewElement) { return this._viewToModelMapping.get(viewElement); } toViewElement(modelElement) { return this._modelToViewMapping.get(modelElement); } /** * Gets the corresponding model range. * * @param viewRange View range. * @returns Corresponding model range. */ toModelRange(viewRange) { return new ModelRange(this.toModelPosition(viewRange.start), this.toModelPosition(viewRange.end)); } /** * Gets the corresponding view range. * * @param modelRange Model range. * @returns Corresponding view range. */ toViewRange(modelRange) { return new ViewRange(this.toViewPosition(modelRange.start), this.toViewPosition(modelRange.end)); } /** * Gets the corresponding model position. * * @fires viewToModelPosition * @param viewPosition View position. * @returns Corresponding model position. */ toModelPosition(viewPosition) { const data = { viewPosition, mapper: this }; this.fire('viewToModelPosition', data); return data.modelPosition; } /** * Gets the corresponding view position. * * @fires modelToViewPosition * @param modelPosition Model position. * @param options Additional options for position mapping process. * @param options.isPhantom 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 Corresponding view position. */ toViewPosition(modelPosition, options = {}) { const data = { modelPosition, mapper: this, isPhantom: options.isPhantom }; this.fire('modelToViewPosition', data); return data.viewPosition; } /** * Gets all view elements bound to the given marker name. * * @param name Marker name. * @returns View elements bound with the given marker name or `null` * if no elements are bound to the given marker name. */ markerNameToElements(name) { const boundElements = this._markerNameToElements.get(name); if (!boundElements) { return null; } const elements = new Set(); for (const element of boundElements) { if (element.is('attributeElement')) { for (const clone of element.getElementsWithSameId()) { elements.add(clone); } } else { elements.add(element); } } return elements; } /** * **This method is deprecated and will be removed in one of the future CKEditor 5 releases.** * * **Using this method will turn off `Mapper` caching system and may degrade performance when operating on bigger documents.** * * Registers a callback that evaluates the length in the model of a view element with the given name. * * The callback is fired with one argument, which is a view element instance. The callback is expected to return * a number representing the length of the view element in the model. * * ```ts * // List item in view may contain nested list, which have other list items. In model though, * // the lists are represented by flat structure. Because of those differences, length of list view element * // may be greater than one. In the callback it's checked how many nested list items are in evaluated list item. * * function getViewListItemLength( element ) { * let length = 1; * * for ( let child of element.getChildren() ) { * if ( child.name == 'ul' || child.name == 'ol' ) { * for ( let item of child.getChildren() ) { * length += getViewListItemLength( item ); * } * } * } * * return length; * } * * mapper.registerViewToModelLength( 'li', getViewListItemLength ); * ``` * * @param viewElementName Name of view element for which callback is registered. * @param lengthCallback Function return a length of view element instance in model. * @deprecated */ registerViewToModelLength(viewElementName, lengthCallback) { this._viewToModelLengthCallbacks.set(viewElementName, lengthCallback); } /** * For the given `viewPosition`, finds and returns the closest ancestor of this position that has a mapping to * the model. * * @param viewPosition Position for which a mapped ancestor should be found. */ findMappedViewAncestor(viewPosition) { let parent = viewPosition.parent; while (!this._viewToModelMapping.has(parent)) { parent = parent.parent; } return parent; } /** * Calculates model offset based on the view position and the block element. * * Example: * * ```html * <p>foo<b>ba|r</b></p> // _toModelOffset( b, 2, p ) -> 5 * ``` * * Is a sum of: * * ```html * <p>foo|<b>bar</b></p> // _toModelOffset( p, 3, p ) -> 3 * <p>foo<b>ba|r</b></p> // _toModelOffset( b, 2, b ) -> 2 * ``` * * @param viewParent Position parent. * @param viewOffset Position offset. * @param viewBlock Block used as a base to calculate offset. * @returns Offset in the model. */ _toModelOffset(viewParent, viewOffset, viewBlock) { if (viewBlock != viewParent) { // See example. const offsetToParentStart = this._toModelOffset(viewParent.parent, viewParent.index, viewBlock); const offsetInParent = this._toModelOffset(viewParent, viewOffset, viewParent); return offsetToParentStart + offsetInParent; } // viewBlock == viewParent, so we need to calculate the offset in the parent element. // If the position is a text it is simple ("ba|r" -> 2). if (viewParent.is('$text')) { return viewOffset; } // If the position is in an element we need to sum lengths of siblings ( <b> bar </b> foo | -> 3 + 3 = 6 ). let modelOffset = 0; for (let i = 0; i < viewOffset; i++) { modelOffset += this.getModelLength(viewParent.getChild(i)); } return modelOffset; } /** * Gets the length of the view element in the model. * * The length is calculated as follows: * * if a {@link #registerViewToModelLength length mapping callback} is provided for the given `viewNode`, it is used to * evaluate the model length (`viewNode` is used as first and only parameter passed to the callback), * * length of a {@link module:engine/view/text~ViewText text node} is equal to the length of its * {@link module:engine/view/text~ViewText#data data}, * * length of a {@link module:engine/view/uielement~ViewUIElement ui element} is equal to 0, * * length of a mapped {@link module:engine/view/element~ViewElement element} is equal to 1, * * length of a non-mapped {@link module:engine/view/element~ViewElement element} is equal to the length of its children. * * Examples: * * ``` * foo -> 3 // Text length is equal to its data length. * <p>foo</p> -> 1 // Length of an element which is mapped is by default equal to 1. * <b>foo</b> -> 3 // Length of an element which is not mapped is a length of its children. * <div><p>x</p><p>y</p></div> -> 2 // Assuming that <div> is not mapped and <p> are mapped. * ``` * * @param viewNode View node. * @returns Length of the node in the tree model. */ getModelLength(viewNode) { const stack = [viewNode]; let len = 0; while (stack.length > 0) { const node = stack.pop(); const callback = node.name && this._viewToModelLengthCallbacks.size > 0 && this._viewToModelLengthCallbacks.get(node.name); if (callback) { len += callback(node); } else if (this._viewToModelMapping.has(node)) { len += 1; } else if (node.is('$text')) { len += node.data.length; } else if (node.is('uiElement')) { continue; } else { for (const child of node.getChildren()) { stack.push(child); } } } return len; } /** * Finds the position in a view element or view document fragment node (or in its children) with the expected model offset. * * If the passed `viewContainer` is bound to model, `Mapper` will use caching mechanism to improve performance. * * @param viewContainer Tree view element in which we are looking for the position. * @param modelOffset Expected offset. * @returns Found position. */ findPositionIn(viewContainer, modelOffset) { if (modelOffset === 0) { // Quickly return if asked for a position at the beginning of the container. No need to fire complex mechanisms to find it. return this._moveViewPositionToTextNode(new ViewPosition(viewContainer, 0)); } // Use cache only if there are no custom view-to-model length callbacks and only for bound elements. // View-to-model length callbacks are deprecated and should be removed in one of the following releases. // Then it will be possible to simplify some logic inside `Mapper`. // Note: we could consider requiring `viewContainer` to be a mapped item. const useCache = this._viewToModelLengthCallbacks.size == 0 && this._viewToModelMapping.has(viewContainer); if (useCache) { const cacheItem = this._cache.getClosest(viewContainer, modelOffset); return this._findPositionStartingFrom(cacheItem.viewPosition, cacheItem.modelOffset, modelOffset, viewContainer, true); } else { return this._findPositionStartingFrom(new ViewPosition(viewContainer, 0), 0, modelOffset, viewContainer, false); } } /** * Performs most of the logic for `Mapper#findPositionIn()`. * * It allows to start looking for the requested model offset from a given starting position, to enable caching. Using the cache, * you can set the starting point and skip all the calculations that were already previously done. * * This method uses recursion to find positions inside deep structures. Example: * * ``` * <p>fo<b>bar</b>bom</p> -> target offset: 4 * <p>|fo<b>bar</b>bom</p> -> target offset: 4, traversed offset: 0 * <p>fo|<b>bar</b>bom</p> -> target offset: 4, traversed offset: 2 * <p>fo<b>bar</b>|bom</p> -> target offset: 4, traversed offset: 5 -> we are too far, look recursively in <b>. * * <p>fo<b>|bar</b>bom</p> -> target offset: 4, traversed offset: 2 * <p>fo<b>bar|</b>bom</p> -> target offset: 4, traversed offset: 5 -> we are too far, look inside "bar". * * <p>fo<b>ba|r</b>bom</p> -> target offset: 4, traversed offset: 2 -> position is inside text node at offset 4-2 = 2. * ``` * * @param startViewPosition View position to start looking from. * @param startModelOffset Model offset related to `startViewPosition`. * @param targetModelOffset Target model offset to find. * @param viewContainer Mapped ancestor of `startViewPosition`. `startModelOffset` is the offset inside a model element or model * document fragment mapped to `viewContainer`. * @param useCache Whether `Mapper` should cache positions while traversing the view tree looking for `expectedModelOffset`. * @returns View position mapped to `targetModelOffset`. */ _findPositionStartingFrom(startViewPosition, startModelOffset, targetModelOffset, viewContainer, useCache) { let viewParent = startViewPosition.parent; let viewOffset = startViewPosition.offset; // In the text node it is simple: the offset in the model equals the offset in the text. if (viewParent.is('$text')) { return new ViewPosition(viewParent, targetModelOffset - startModelOffset); } // Last scanned view node. let viewNode; // Total model offset of the view nodes that were visited so far. let traversedModelOffset = startModelOffset; // Model length of the last traversed view node. let lastLength = 0; while (traversedModelOffset < targetModelOffset) { viewNode = viewParent.getChild(viewOffset); if (!viewNode) { // If we still haven't reached the model offset, but we reached end of this `viewParent`, then we need to "leave" this // element and "go up", looking further for the target model offset. This can happen when cached model offset is "deeper" // but target model offset is "higher" in the view tree. // // Example: `<p>Foo<strong><em>Bar</em>^Baz</strong>Xyz</p>` // // Consider `^` is last cached position, when the `targetModelOffset` is `12`. In such case, we need to "go up" from // `<strong>` and continue traversing in `<p>`. // if (viewParent == viewContainer) { /** * A model position could not be mapped to the view because specified model offset was too big and could not be * found inside the mapped view element or view document fragment. * * @error mapping-model-offset-not-found */ throw new CKEditorError('mapping-model-offset-not-found', this, { modelOffset: targetModelOffset, viewContainer }); } else { viewOffset = viewParent.parent.getChildIndex(viewParent) + 1; viewParent = viewParent.parent; // Cache view position after stepping out of the view element to make sure that all visited view positions are cached. // Otherwise, cache invalidation may work incorrectly. if (useCache) { this._cache.save(viewParent, viewOffset, viewContainer, traversedModelOffset); } continue; } } if (useCache) { lastLength = this._getModelLengthAndCache(viewNode, viewContainer, traversedModelOffset); } else { lastLength = this.getModelLength(viewNode); } traversedModelOffset += lastLength; viewOffset++; } let viewPosition = new ViewPosition(viewParent, viewOffset); if (useCache) { // Make sure to hoist view position and save cache for all view positions along the way that have the same `modelOffset`. // // Consider example view: // // <p>Foo<strong><i>bar<em>xyz</em></i></strong>abc</p> // // Lets assume that we looked for model offset `9`, starting from `6` (as it was previously cached value). // In this case we will only traverse over `<em>xyz</em>` and cache view positions after "xyz" and after `</em>`. // After stepping over `<em>xyz</em>`, we will stop processing this view, as we will reach target model offset `9`. // // However, position before `</strong>` and before `abc` are also valid positions for model offset `9`. // Additionally, `Mapper` is supposed to return "hoisted" view positions, that is, we prefer positions that are closer to // the mapped `viewContainer`. If a position is nested inside attribute elements, it should be "moved up" if possible. // // As we hoist the view position, we need to make sure that all view positions valid for model offset `9` are cached. // This is necessary for cache invalidation to work correctly. // // To hoist a view position, we "go up" as long as the position is at the end of a non-mapped view element. We also cache // all necessary values. See example: // // <p>Foo<strong><i>bar<em>xyz</em>^</i></strong>abc</p> // <p>Foo<strong><i>bar<em>xyz</em></i>^</strong>abc</p> // <p>Foo<strong><i>bar<em>xyz</em></i></strong>^abc</p> // while (viewPosition.isAtEnd && viewPosition.parent !== viewContainer && viewPosition.parent.parent) { const cacheViewParent = viewPosition.parent.parent; const cacheViewOffset = cacheViewParent.getChildIndex(viewPosition.parent) + 1; this._cache.save(cacheViewParent, cacheViewOffset, viewContainer, traversedModelOffset); viewPosition = new ViewPosition(cacheViewParent, cacheViewOffset); } } if (traversedModelOffset == targetModelOffset) { // If it equals we found the position. // // Try moving view position into a text node if possible, as the editor engine prefers positions inside view text nodes. // // <p>Foo<strong><i>bar<em>xyz</em></i></strong>[]abc</p> --> <p>Foo<strong><i>bar<em>xyz</em></i></strong>{}abc</p> // return this._moveViewPositionToTextNode(viewPosition); } else { // If it is higher we overstepped with the last traversed view node. // We need to "enter" it, and look for the view position / model offset inside the last visited view node. return this._findPositionStartingFrom(new ViewPosition(viewNode, 0), traversedModelOffset - lastLength, targetModelOffset, viewContainer, useCache); } } /** * Gets the length of the view element in the model and updates cache values after each view item it visits. * * See also {@link #getModelLength}. * * @param viewNode View node. * @param viewContainer Ancestor of `viewNode` that is a mapped view element. * @param modelOffset Model offset at which the `viewNode` starts. * @returns Length of the node in the tree model. */ _getModelLengthAndCache(viewNode, viewContainer, modelOffset) { let len = 0; if (this._viewToModelMapping.has(viewNode)) { len = 1; } else if (viewNode.is('$text')) { len = viewNode.data.length; } else if (!viewNode.is('uiElement')) { for (const child of viewNode.getChildren()) { len += this._getModelLengthAndCache(child, viewContainer, modelOffset + len); } } this._cache.save(viewNode.parent, viewNode.index + 1, viewContainer, modelOffset + len); return len; } /** * Because we prefer positions in the text nodes over positions next to text nodes, if the view position was next to a text node, * it moves it into the text node instead. * * ``` * <p>[]<b>foo</b></p> -> <p>[]<b>foo</b></p> // do not touch if position is not directly next to text * <p>foo[]<b>foo</b></p> -> <p>foo{}<b>foo</b></p> // move to text node * <p><b>[]foo</b></p> -> <p><b>{}foo</b></p> // move to text node * ``` * * @param viewPosition Position potentially next to the text node. * @returns Position in the text node if possible. */ _moveViewPositionToTextNode(viewPosition) { // If the position is just after a text node, put it at the end of that text node. // If the position is just before a text node, put it at the beginning of that text node. const nodeBefore = viewPosition.nodeBefore; const nodeAfter = viewPosition.nodeAfter; if (nodeBefore && nodeBefore.is('view:$text')) { return new ViewPosition(nodeBefore, nodeBefore.data.length); } else if (nodeAfter && nodeAfter.is('view:$text')) { return new ViewPosition(nodeAfter, 0); } // Otherwise, just return the given position. return viewPosition; } } /** * Cache mechanism for {@link module:engine/conversion/mapper~Mapper Mapper}. * * `MapperCache` improves performance for model-to-view position mapping, which is the main `Mapper` task. Asking for a mapping is much * more frequent than actually performing changes, and even if the change happens, we can still partially keep the cache. This makes * caching a useful strategy for `Mapper`. * * `MapperCache` will store some data for view elements or view document fragments that are mapped by the `Mapper`. These view items * are "tracked" by the `MapperCache`. For such view items, we will keep entries of model offsets inside their mapped counterpart. For * the cached model offsets, we will keep a view position that is inside the tracked item. This allows us to either get the mapping * instantly, or at least in less steps than when calculating it from the beginning. * * Important problem related to caching is invalidating the cache. The cache must be invalidated each time the tracked view item changes. * Additionally, we should invalidate as small part of the cache as possible. Since all the logic is encapsulated inside `MapperCache`, * the `MapperCache` listens to view items {@link module:engine/view/node~ViewNodeChangeEvent `change` event} and reacts to it. * Then, it invalidates just the part of the cache that is "after" the changed part of the view. * * As mentioned, `MapperCache` currently is used only for model-to-view position mapping as it was much bigger problem than view-to-model * mapping. However, it should be possible to use it also for view-to-model. * * The main assumptions regarding `MapperCache` are: * * * it is an internal tool, used by `Mapper`, transparent to the outside (no additional effort when developing a plugin or a converter), * * it stores all the necessary data internally, which makes it easier to disable or debug, * * it is optimized for initial downcast process (long insertions), which is crucial for editor init and data save, * * it does not save all possible positions mapping for memory considerations, although it is a possible improvement, which may increase * performance, as well as simplify some parts of the `MapperCache` logic. * * @internal */ export class MapperCache extends /* #__PURE__ */ EmitterMixin() { /** * For every view element or document fragment tracked by `MapperCache`, it holds currently cached data, or more precisely, * model offset to view position mappings. See also `MappingCache` and `CacheItem`. * * If an item is tracked by `MapperCache` it has an entry in this structure, so this structure can be used to check which items * are tracked by `MapperCache`. When an item is no longer tracked, it is removed from this structure. * * Although `MappingCache` and `CacheItem` structures allows for caching any model offsets and view positions, we only cache * values for model offsets that are after a view node. So, in essence, positions inside text nodes are not cached. However, it takes * from one to at most a few steps, to get from a cached position to a position that is inside a view text node. * * Additionally, only one item per `modelOffset` is cached. There can be several view positions that map to the same `modelOffset`. * Only the first save for `modelOffset` is stored. */ _cachedMapping = new WeakMap(); /** * When `MapperCache` {@link ~MapperCache#save saves} view position -> model offset mapping, a `CacheItem` is inserted into certain * `MappingCache#cacheList` at some index. Additionally, we store that index with the view node that is before the cached view position. * * This allows to quickly get a cache list item related to certain view node, and hence, for fast cache invalidation. * * For example, consider view: `<p>Some <strong>bold</strong> text.</p>`, where `<p>` is a view element tracked by `MapperCache`. * If all `<p>` children were visited by `MapperCache`, then `<p>` cache list would have four items, related to following model offsets: * `0`, `5`, `9`, `15`. Then, view node `"Some "` would have index `1`, `<strong>` index `2`, and `" text." index `3`. * * Note that the index related with a node is always greater than `0`. The first item in cache list is always for model offset `0` * (and view offset `0`), and it is not related to any node. */ _nodeToCacheListIndex = new WeakMap(); /** * Callback fired whenever there is a direct or indirect children change in tracked view element or tracked view document fragment. * * This is specified as a property to make it easier to set as an event callback and to later turn off that event. */ _invalidateOnChildrenChangeCallback = (evt, viewNode, data) => { // View element or document fragment changed its children at `data.index`. Clear all cache starting from before that index. this._clearCacheInsideParent(viewNode, data.index); }; /** * Callback fired whenever a view text node directly or indirectly inside a tracked view element or tracked view document fragment * changes its text data. * * This is specified as a property to make it easier to set as an event callback and to later turn off that event. */ _invalidateOnTextChangeCallback = (evt, viewNode) => { // It is enough to validate starting from "after the text node", because the first cache entry that we might want to invalidate // is the position after this text node. // // For example - assume following view and following view positions cached (marked by `^`): `<p>Foo^<strong>bar^</strong>^abc^</p>`. // // If we change text "bar", we only need to invalidate cached positions after it. Cached positions before the text are not changed. // this._clearCacheAfter(viewNode); }; /** * Saves cache for given view position mapping <-> model offset mapping. The view position should be after a node (i.e. it cannot * be the first position inside its parent, or in other words, `viewOffset` must be greater than `0`). * * Note, that if `modelOffset` for given `viewContainer` was already saved, the stored view position (i.e. parent+offset) will not * be overwritten. However, it is important to still save it, as we still store additional data related to cached view positions. * * @param viewParent View position parent. * @param viewOffset View position offset. Must be greater than `0`. * @param viewContainer Tracked view position ascendant (it may be the direct parent of the view position). * @param modelOffset Model offset in the model element or document fragment which is mapped to `viewContainer`. */ save(viewParent, viewOffset, viewContainer, modelOffset) { // Get current cache for the tracked ancestor. const cache = this._cachedMapping.get(viewContainer); // See if there is already a cache defined for `modelOffset`. const cacheItem = cache.cacheMap.get(modelOffset); if (cacheItem) { // We already cached this offset. Don't overwrite the cache. // However, we still need to set a proper entry in `_nodeToCacheListIndex`. We can figure it out based on existing `cacheItem`. // // We have a `cacheItem` for the `modelOffset`, so we can get a `viewPosition` from there. Before that view position, there // must be a node. That node must have an index set. This will be the index we will want to use. // Since we expect `viewOffset` to be greater than 0, then in almost all cases `modelOffset` will be greater than 0 as well. // As a result, we can expect `cacheItem.viewPosition.nodeBefore` to be set. // // However, in an edge case, were the tracked element contains a 0-model-length view element as the first child (UI element or // an empty attribute element), then `modelOffset` will be 0, and `cacheItem.viewPosition` will be before any view node. // In such edge case, `cacheItem.viewPosition.nodeBefore` is `undefined`, so we set index to `0`. // const viewChild = viewParent.getChild(viewOffset - 1); const index = cacheItem.viewPosition.nodeBefore ? this._nodeToCacheListIndex.get(cacheItem.viewPosition.nodeBefore) : 0; this._nodeToCacheListIndex.set(viewChild, index); return; } const viewPosition = new ViewPosition(viewParent, viewOffset); const newCacheItem = { viewPosition, modelOffset }; // Extend the valid cache range. cache.maxModelOffset = modelOffset > cache.maxModelOffset ? modelOffset : cache.maxModelOffset; // Save the new cache item to the `cacheMap`. cache.cacheMap.set(modelOffset, newCacheItem); // Save the new cache item to the `cacheList`. let i = cache.cacheList.length - 1; // Mostly, we cache elements at the end of `cacheList` and the loop does not execute even once. But when we recursively visit nodes // in `Mapper#_findPositionIn()`, then we will first cache the parent, and then it's children, and they will not be added at the // end of `cacheList`. This is why we need to find correct index to insert them. while (i >= 0 && cache.cacheList[i].modelOffset > modelOffset) { i--; } cache.cacheList.splice(i + 1, 0, newCacheItem); if (viewOffset > 0) { const viewChild = viewParent.getChild(viewOffset - 1); // There was an idea to also cache `viewContainer` here but, it could lead to wrong results. If we wanted to cache // `viewContainer`, we probably would need to clear `this._nodeToCacheListIndex` when cache is cleared. // Also, there was no gain from caching this value, the results were almost the same (statistical error). this._nodeToCacheListIndex.set(viewChild, i + 1); } } /** * For given `modelOffset` inside a model element mapped to given `viewContainer`, it returns the closest saved cache item * (view position and related model offset) to the requested one. * * It can be exactly the requested mapping, or it can be mapping that is the closest starting point to look for the requested mapping. * * `viewContainer` must be a view element or document fragment that is mapped by the {@link ~Mapper Mapper}. * * If `viewContainer` is not yet tracked by the `MapperCache`, it will be automatically tracked after calling this method. * * Note: this method will automatically "hoist" cached positions, i.e. it will return a position that is closest to the tracked element. * * For example, if `<p>` is tracked element, and `^` is cached position: * * ``` * <p>This is <strong>some <em>heavily <u>formatted</u>^</em></strong> text.</p> * ``` * * If this position would be returned, instead, a position directly in `<p>` would be returned: * * ``` * <p>This is <strong>some <em>heavily <u>formatted</u></em></strong>^ text.</p> * ``` * * Note, that `modelOffset` for both positions is the same. * * @param viewContainer Tracked view element or document fragment, which cache will be used. * @param modelOffset Model offset in a model element or document fragment, which is mapped to `viewContainer`. */ getClosest(viewContainer, modelOffset) { const cache = this._cachedMapping.get(viewContainer); let result; if (cache) { if (modelOffset > cache.maxModelOffset) { result = cache.cacheList[cache.cacheList.length - 1]; } else { const cacheItem = cache.cacheMap.get(modelOffset); if (cacheItem) { result = cacheItem; } else { result = this._findInCacheList(cache.cacheList, modelOffset); } } } else { result = this.startTracking(viewContainer); } return { modelOffset: result.modelOffset, viewPosition: result.viewPosition.clone() }; } /** * Starts tracking given `viewContainer`, which must be mapped to a model element or model document fragment. * * Note, that this method is automatically called by * {@link module:engine/conversion/mapper~MapperCache#getClosest `MapperCache#getClosest()`} and there is no need to call it manually. * * This method initializes the cache for `viewContainer` and adds callbacks for * {@link module:engine/view/node~ViewNodeChangeEvent `change` event} fired by `viewContainer`. `MapperCache` listens to `change` event * on the tracked elements to invalidate the stored cache. */ startTracking(viewContainer) { const viewPosition = new ViewPosition(viewContainer, 0); const initialCacheItem = { viewPosition, modelOffset: 0 }; const initialCache = { maxModelOffset: 0, cacheList: [initialCacheItem], cacheMap: new Map([[0, initialCacheItem]]) }; this._cachedMapping.set(viewContainer, initialCache); // Listen to changes in tracked view containers in order to invalidate the cache. // // Possible performance improvement. This event bubbles, so if there are multiple tracked (mapped) elements that are ancestors // then this will be unnecessarily fired for each ancestor. This could be rewritten to listen only to roots and document fragments. viewContainer.on('change:children', this._invalidateOnChildrenChangeCallback); viewContainer.on('change:text', this._invalidateOnTextChangeCallback); return initialCacheItem; } /** * Stops tracking given `viewContainer`. * * It removes the cached data and stops listening to {@link module:engine/view/node~ViewNodeChangeEvent `change` event} on the * `viewContainer`. */ stopTracking(viewContainer) { viewContainer.off('change:children', this._invalidateOnChildrenChangeCallback); viewContainer.off('change:text', this._invalidateOnTextChangeCallback); this._cachedMapping.delete(viewContainer); } /** * Invalidates cache inside `viewParent`, starting from given `index` in that parent. * * This method may clear a bit more cache than just what was saved after given `index`, but it is guaranteed that at least it * will invalidate everything after `index`. */ _clearCacheInsideParent(viewParent, index) { if (index == 0) { // Change at the beginning of the parent. if (this._cachedMapping.has(viewParent)) { // If this is a tracked element, clear all cache. this._clearCacheAll(viewParent); } else { // If this is not a tracked element, remove cache starting from before this element. // Since it is not a tracked element, it has to have a parent. this._clearCacheInsideParent(viewParent.parent, viewParent.index); } } else { // Change in the middle of the parent. Get a view node that's before the change. const lastValidNode = viewParent.getChild(index - 1); // Then, clear all cache after this view node. // this._clearCacheAfter(lastValidNode); } } /** * Clears all the cache for given tracked `viewContainer`. */ _clearCacheAll(viewContainer) { const cache = this._cachedMapping.get(viewContainer); // TODO: Should clear `_nodeToCacheListIndex` too? if (cache.maxModelOffset > 0) { cache.maxModelOffset = 0; cache.cacheList.length = 1; cache.cacheMap.clear(); cache.cacheMap.set(0, cache.cacheList[0]); } } /** * Clears all the stored cache that is after given `viewNode`. The `viewNode` can be any node that is inside a tracked view element * or view document fragment. * * In reality, this function may clear a bit more cache than just "starting after" `viewNode`, but it is guaranteed that at least * all cache after `viewNode` is invalidated. */ _clearCacheAfter(viewNode) { // To quickly invalidate the cache, we base on the cache list index stored with the node. See docs for `this._nodeToCacheListIndex`. const cacheListIndex = this._nodeToCacheListIndex.get(viewNode); // If there is no index stored, it means that this `viewNode` has not been cached yet. if (cacheListIndex === undefined) { // If the node is not cached, maybe it's parent is. We will try to invalidate the cache using the parent. const viewParent = viewNode.parent; // If the parent is a non-tracked element, try clearing the cache starting from the position before it. // // For example: `<p>Abc<strong>def<em>ghi</em></strong></p>`. // // If `viewNode` is `<em>` in this case, and it was not cached yet, we will try to clear cache starting from before `<strong>`. // // If the parent is a tracked element, then it means there's no cache to clear (nothing after the element is cached). // In this case, there's nothing to do. We assume that there are no "holes" in caching in direct children of tracked element // (that is if some children is cached, then its previous sibling is cached too, and we would not end up inside this `if`). // // TODO: Most probably this `if` could be removed altogether, after recent changes in Mapper. // TODO: Now we cache all items one after a