@ckeditor/ckeditor5-engine
Version:
The editing engine of CKEditor 5 – the best browser-based rich text editor.
945 lines • 115 kB
JavaScript
/**
* @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/model/operation/transform
*/
import { InsertOperation } from './insertoperation.js';
import { AttributeOperation } from './attributeoperation.js';
import { RenameOperation } from './renameoperation.js';
import { MarkerOperation } from './markeroperation.js';
import { MoveOperation } from './moveoperation.js';
import { RootAttributeOperation } from './rootattributeoperation.js';
import { RootOperation } from './rootoperation.js';
import { MergeOperation } from './mergeoperation.js';
import { SplitOperation } from './splitoperation.js';
import { NoOperation } from './nooperation.js';
import { ModelRange } from '../range.js';
import { ModelPosition } from '../position.js';
import { compareArrays } from '@ckeditor/ckeditor5-utils';
const transformations = new Map();
/**
* Sets a transformation function to be be used to transform instances of class `OperationA` by instances of class `OperationB`.
*
* The `transformationFunction` is passed three parameters:
*
* * `a` - operation to be transformed, an instance of `OperationA`,
* * `b` - operation to be transformed by, an instance of `OperationB`,
* * {@link module:engine/model/operation/transform~TransformationContext `context`} - object with additional information about
* transformation context.
*
* The `transformationFunction` should return transformation result, which is an array with one or multiple
* {@link module:engine/model/operation/operation~Operation operation} instances.
*
* @param transformationFunction Function to use for transforming.
*/
function setTransformation(OperationA, OperationB, transformationFunction) {
let aGroup = transformations.get(OperationA);
if (!aGroup) {
aGroup = new Map();
transformations.set(OperationA, aGroup);
}
aGroup.set(OperationB, transformationFunction);
}
/**
* Returns a previously set transformation function for transforming an instance of `OperationA` by an instance of `OperationB`.
*
* If no transformation was set for given pair of operations, {@link module:engine/model/operation/transform~noUpdateTransformation}
* is returned. This means that if no transformation was set, the `OperationA` instance will not change when transformed
* by the `OperationB` instance.
*
* @returns Function set to transform an instance of `OperationA` by an instance of `OperationB`.
*/
function getTransformation(OperationA, OperationB) {
const aGroup = transformations.get(OperationA);
if (aGroup && aGroup.has(OperationB)) {
return aGroup.get(OperationB);
}
return noUpdateTransformation;
}
/**
* A transformation function that only clones operation to transform, without changing it.
*/
function noUpdateTransformation(a) {
return [a];
}
/**
* Transforms operation `a` by operation `b`.
*
* @param a Operation to be transformed.
* @param b Operation to transform by.
* @param context Transformation context for this transformation.
* @returns Transformation result.
* @internal
*/
export function transform(a, b, context = {}) {
const transformationFunction = getTransformation(a.constructor, b.constructor);
/* eslint-disable no-useless-catch */
try {
a = a.clone();
return transformationFunction(a, b, context);
}
catch (e) {
// @if CK_DEBUG // console.warn( 'Error during operation transformation!', e.message );
// @if CK_DEBUG // console.warn( 'Transformed operation', a );
// @if CK_DEBUG // console.warn( 'Operation transformed by', b );
// @if CK_DEBUG // console.warn( 'context.aIsStrong', context.aIsStrong );
// @if CK_DEBUG // console.warn( 'context.aWasUndone', context.aWasUndone );
// @if CK_DEBUG // console.warn( 'context.bWasUndone', context.bWasUndone );
// @if CK_DEBUG // console.warn( 'context.abRelation', context.abRelation );
// @if CK_DEBUG // console.warn( 'context.baRelation', context.baRelation );
throw e;
}
/* eslint-enable no-useless-catch */
}
/**
* Performs a transformation of two sets of operations - `operationsA` and `operationsB`. The transformation is two-way -
* both transformed `operationsA` and transformed `operationsB` are returned.
*
* Note, that the first operation in each set should base on the same document state (
* {@link module:engine/model/document~ModelDocument#version document version}).
*
* It is assumed that `operationsA` are "more important" during conflict resolution between two operations.
*
* New copies of both passed arrays and operations inside them are returned. Passed arguments are not altered.
*
* Base versions of the transformed operations sets are updated accordingly. For example, assume that base versions are `4`
* and there are `3` operations in `operationsA` and `5` operations in `operationsB`. Then:
*
* * transformed `operationsA` will start from base version `9` (`4` base version + `5` operations B),
* * transformed `operationsB` will start from base version `7` (`4` base version + `3` operations A).
*
* If no operation was broken into two during transformation, then both sets will end up with an operation that bases on version `11`:
*
* * transformed `operationsA` start from `9` and there are `3` of them, so the last will have `baseVersion` equal to `11`,
* * transformed `operationsB` start from `7` and there are `5` of them, so the last will have `baseVersion` equal to `11`.
*
* @param operationsA
* @param operationsB
* @param options Additional transformation options.
* @param options.document Document which the operations change.
* @param options.useRelations Whether during transformation relations should be used (used during undo for better conflict resolution).
* @param options.padWithNoOps Whether additional {@link module:engine/model/operation/nooperation~NoOperation}s
* should be added to the transformation results to force the same last base version for both transformed sets (in case
* if some operations got broken into multiple operations during transformation).
* @param options.forceWeakRemove If set to `false`, remove operation will be always stronger than move operation,
* so the removed nodes won't end up back in the document root. When set to `true`, context data will be used.
* @returns Transformation result.
*/
export function transformOperationSets(operationsA, operationsB, options) {
// Create new arrays so the originally passed arguments are not changed.
// No need to clone operations, they are cloned as they are transformed.
operationsA = operationsA.slice();
operationsB = operationsB.slice();
const contextFactory = new ContextFactory(options.document, options.useRelations, options.forceWeakRemove);
contextFactory.setOriginalOperations(operationsA);
contextFactory.setOriginalOperations(operationsB);
const originalOperations = contextFactory.originalOperations;
// If one of sets is empty there is simply nothing to transform, so return sets as they are.
if (operationsA.length == 0 || operationsB.length == 0) {
return { operationsA, operationsB, originalOperations };
}
//
// Following is a description of transformation process:
//
// There are `operationsA` and `operationsB` to be transformed, both by both.
//
// So, suppose we have sets of two operations each: `operationsA` = `[ a1, a2 ]`, `operationsB` = `[ b1, b2 ]`.
//
// Remember, that we can only transform operations that base on the same context. We assert that `a1` and `b1` base on
// the same context and we transform them. Then, we get `a1'` and `b1'`. `a2` bases on a context with `a1` -- `a2`
// is an operation that followed `a1`. Similarly, `b2` bases on a context with `b1`.
//
// However, since `a1'` is a result of transformation by `b1`, `a1'` now also has a context with `b1`. This means that
// we can safely transform `a1'` by `b2`. As we finish transforming `a1`, we also transformed all `operationsB`.
// All `operationsB` also have context including `a1`. Now, we can properly transform `a2` by those operations.
//
// The transformation process can be visualized on a transformation diagram ("diamond diagram"):
//
// [the initial state]
// [common for a1 and b1]
//
// *
// / \
// / \
// b1 a1
// / \
// / \
// * *
// / \ / \
// / \ / \
// b2 a1' b1' a2
// / \ / \
// / \ / \
// * * *
// \ / \ /
// \ / \ /
// a1'' b2' a2' b1''
// \ / \ /
// \ / \ /
// * *
// \ /
// \ /
// a2'' b2''
// \ /
// \ /
// *
//
// [the final state]
//
// The final state can be reached from the initial state by applying `a1`, `a2`, `b1''` and `b2''`, as well as by
// applying `b1`, `b2`, `a1''`, `a2''`. Note how the operations get to a proper common state before each pair is
// transformed.
//
// Another thing to consider is that an operation during transformation can be broken into multiple operations.
// Suppose that `a1` * `b1` = `[ a11', a12' ]` (instead of `a1'` that we considered previously).
//
// In that case, we leave `a12'` for later and we continue transforming `a11'` until it is transformed by all `operationsB`
// (in our case it is just `b2`). At this point, `b1` is transformed by "whole" `a1`, while `b2` is only transformed
// by `a11'`. Similarly, `a12'` is only transformed by `b1`. This leads to a conclusion that we need to start transforming `a12'`
// from the moment just after it was broken. So, `a12'` is transformed by `b2`. Now, "the whole" `a1` is transformed
// by `operationsB`, while all `operationsB` are transformed by "the whole" `a1`. This means that we can continue with
// following `operationsA` (in our case it is just `a2`).
//
// Of course, also `operationsB` can be broken. However, since we focus on transforming operation `a` to the end,
// the only thing to do is to store both pieces of operation `b`, so that the next transformed operation `a` will
// be transformed by both of them.
//
// *
// / \
// / \
// / \
// b1 a1
// / \
// / \
// / \
// * *
// / \ / \
// / a11' / \
// / \ / \
// b2 * b1' a2
// / / \ / \
// / / a12' / \
// / / \ / \
// * b2' * *
// \ / / \ /
// a11'' / b21'' \ /
// \ / / \ /
// * * a2' b1''
// \ / \ \ /
// a12'' b22''\ \ /
// \ / \ \ /
// * a2'' *
// \ \ /
// \ \ b21'''
// \ \ /
// a2''' *
// \ /
// \ b22'''
// \ /
// *
//
// Note, how `a1` is broken and transformed into `a11'` and `a12'`, while `b2'` got broken and transformed into `b21''` and `b22''`.
//
// Having all that on mind, here is an outline for the transformation process algorithm:
//
// 1. We have `operationsA` and `operationsB` array, which we dynamically update as the transformation process goes.
//
// 2. We take next (or first) operation from `operationsA` and check from which operation `b` we need to start transforming it.
// All original `operationsA` are set to be transformed starting from the first operation `b`.
//
// 3. We take operations from `operationsB`, one by one, starting from the correct one, and transform operation `a`
// by operation `b` (and vice versa). We update `operationsA` and `operationsB` by replacing the original operations
// with the transformation results.
//
// 4. If operation is broken into multiple operations, we save all the new operations in the place of the
// original operation.
//
// 5. Additionally, if operation `a` was broken, for the "new" operation, we remember from which operation `b` it should
// be transformed by.
//
// 6. We continue transforming "current" operation `a` until it is transformed by all `operationsB`. Then, go to 2.
// unless the last operation `a` was transformed.
//
// The actual implementation of the above algorithm is slightly different, as only one loop (while) is used.
// The difference is that we have "current" `a` operation to transform and we store the index of the next `b` operation
// to transform by. Each loop operates on two indexes then: index pointing to currently processed `a` operation and
// index pointing to next `b` operation. Each loop is just one `a * b` + `b * a` transformation. After each loop
// operation `b` index is updated. If all `b` operations were visited for the current `a` operation, we change
// current `a` operation index to the next one.
//
// For each operation `a`, keeps information what is the index in `operationsB` from which the transformation should start.
const nextTransformIndex = new WeakMap();
// For all the original `operationsA`, set that they should be transformed starting from the first of `operationsB`.
for (const op of operationsA) {
nextTransformIndex.set(op, 0);
}
// Additional data that is used for some postprocessing after the main transformation process is done.
const data = {
nextBaseVersionA: operationsA[operationsA.length - 1].baseVersion + 1,
nextBaseVersionB: operationsB[operationsB.length - 1].baseVersion + 1,
originalOperationsACount: operationsA.length,
originalOperationsBCount: operationsB.length
};
// Index of currently transformed operation `a`.
let i = 0;
// While not all `operationsA` are transformed...
while (i < operationsA.length) {
// Get "current" operation `a`.
const opA = operationsA[i];
// For the "current" operation `a`, get the index of the next operation `b` to transform by.
const indexB = nextTransformIndex.get(opA);
// If operation `a` was already transformed by every operation `b`, change "current" operation `a` to the next one.
if (indexB == operationsB.length) {
i++;
continue;
}
const opB = operationsB[indexB];
// Transform `a` by `b` and `b` by `a`.
const newOpsA = transform(opA, opB, contextFactory.getContext(opA, opB, true));
const newOpsB = transform(opB, opA, contextFactory.getContext(opB, opA, false));
// As a result we get one or more `newOpsA` and one or more `newOpsB` operations.
// Update contextual information about operations.
contextFactory.updateRelation(opA, opB);
contextFactory.setOriginalOperations(newOpsA, opA);
contextFactory.setOriginalOperations(newOpsB, opB);
// For new `a` operations, update their index of the next operation `b` to transform them by.
//
// This is needed even if there was only one result (`a` was not broken) because that information is used
// at the beginning of this loop every time.
for (const newOpA of newOpsA) {
// Acknowledge, that operation `b` also might be broken into multiple operations.
//
// This is why we raise `indexB` not just by 1. If `newOpsB` are multiple operations, they will be
// spliced in the place of `opB`. So we need to change `transformBy` accordingly, so that an operation won't
// be transformed by the same operation (part of it) again.
nextTransformIndex.set(newOpA, indexB + newOpsB.length);
}
// Update `operationsA` and `operationsB` with the transformed versions.
operationsA.splice(i, 1, ...newOpsA);
operationsB.splice(indexB, 1, ...newOpsB);
}
handlePartialMarkerOperations(operationsA);
handlePartialMarkerOperations(operationsB);
if (options.padWithNoOps) {
// If no-operations padding is enabled, count how many extra `a` and `b` operations were generated.
const brokenOperationsACount = operationsA.length - data.originalOperationsACount;
const brokenOperationsBCount = operationsB.length - data.originalOperationsBCount;
// Then, if that number is not the same, pad `operationsA` or `operationsB` with correct number of no-ops so
// that the base versions are equalled.
//
// Note that only one array will be updated, as only one of those subtractions can be greater than zero.
padWithNoOps(operationsA, brokenOperationsBCount - brokenOperationsACount);
padWithNoOps(operationsB, brokenOperationsACount - brokenOperationsBCount);
}
// Finally, update base versions of transformed operations.
updateBaseVersions(operationsA, data.nextBaseVersionB);
updateBaseVersions(operationsB, data.nextBaseVersionA);
return { operationsA, operationsB, originalOperations };
}
/**
* Gathers additional data about operations processed during transformation. Can be used to obtain contextual information
* about two operations that are about to be transformed. This contextual information can be used for better conflict resolution.
*/
class ContextFactory {
originalOperations;
_history;
_useRelations;
_forceWeakRemove;
_relations;
/**
* Creates `ContextFactory` instance.
*
* @param document Document which the operations change.
* @param useRelations Whether during transformation relations should be used (used during undo for
* better conflict resolution).
* @param forceWeakRemove If set to `false`, remove operation will be always stronger than move operation,
* so the removed nodes won't end up back in the document root. When set to `true`, context data will be used.
*/
constructor(document, useRelations, forceWeakRemove = false) {
// For each operation that is created during transformation process, we keep a reference to the original operation
// which it comes from. The original operation works as a kind of "identifier". Every contextual information
// gathered during transformation that we want to save for given operation, is actually saved for the original operation.
// This way no matter if operation `a` is cloned, then transformed, even breaks, we still have access to the previously
// gathered data through original operation reference.
this.originalOperations = new Map();
// `model.History` instance which information about undone operations will be taken from.
this._history = document.history;
// Whether additional context should be used.
this._useRelations = useRelations;
this._forceWeakRemove = !!forceWeakRemove;
// Relations is a double-map structure (maps in map) where for two operations we store how those operations were related
// to each other. Those relations are evaluated during transformation process. For every transformated pair of operations
// we keep relations between them.
this._relations = new Map();
}
/**
* Sets "original operation" for given operations.
*
* During transformation process, operations are cloned, then changed, then processed again, sometimes broken into two
* or multiple operations. When gathering additional data it is important that all operations can be somehow linked
* so a cloned and transformed "version" still kept track of the data assigned earlier to it.
*
* The original operation object will be used as such an universal linking id. Throughout the transformation process
* all cloned operations will refer to "the original operation" when storing and reading additional data.
*
* If `takeFrom` is not set, each operation from `operations` array will be assigned itself as "the original operation".
* This should be used as an initialization step.
*
* If `takeFrom` is set, each operation from `operations` will be assigned the same original operation as assigned
* for `takeFrom` operation. This should be used to update original operations. It should be used in a way that
* `operations` are the result of `takeFrom` transformation to ensure proper "original operation propagation".
*/
setOriginalOperations(operations, takeFrom = null) {
const originalOperation = takeFrom ? this.originalOperations.get(takeFrom) : null;
for (const operation of operations) {
this.originalOperations.set(operation, originalOperation || operation);
}
}
/**
* Saves a relation between operations `opA` and `opB`.
*
* Relations are then later used to help solve conflicts when operations are transformed.
*/
updateRelation(opA, opB) {
// The use of relations is described in a bigger detail in transformation functions.
//
// In brief, this function, for specified pairs of operation types, checks how positions defined in those operations relate.
// Then those relations are saved. For example, for two move operations, it is saved if one of those operations target
// position is before the other operation source position. This kind of information gives contextual information when
// transformation is used during undo. Similar checks are done for other pairs of operations.
//
if (opA instanceof MoveOperation) {
if (opB instanceof MergeOperation) {
if (opA.targetPosition.isEqual(opB.sourcePosition) || opB.movedRange.containsPosition(opA.targetPosition)) {
this._setRelation(opA, opB, 'insertAtSource');
}
else if (opA.targetPosition.isEqual(opB.deletionPosition)) {
this._setRelation(opA, opB, 'insertBetween');
}
else if (opA.targetPosition.isAfter(opB.sourcePosition)) {
this._setRelation(opA, opB, 'moveTargetAfter');
}
else if (opA.howMany > 1 && opA.sourcePosition.isEqual(opB.deletionPosition)) {
this._setRelation(opA, opB, 'firstToMoveMerged');
}
else if (opA.howMany > 1 && opA.sourcePosition.getShiftedBy(opA.howMany - 1).isEqual(opB.deletionPosition)) {
this._setRelation(opA, opB, 'lastToMoveMerged');
}
}
else if (opB instanceof MoveOperation) {
if (opA.targetPosition.isEqual(opB.sourcePosition) || opA.targetPosition.isBefore(opB.sourcePosition)) {
this._setRelation(opA, opB, 'insertBefore');
}
else {
this._setRelation(opA, opB, 'insertAfter');
}
}
}
else if (opA instanceof SplitOperation) {
if (opB instanceof MergeOperation) {
if (opA.splitPosition.isBefore(opB.sourcePosition)) {
this._setRelation(opA, opB, 'splitBefore');
}
}
else if (opB instanceof MoveOperation) {
if (opA.splitPosition.isEqual(opB.sourcePosition) || opA.splitPosition.isBefore(opB.sourcePosition)) {
this._setRelation(opA, opB, 'splitBefore');
}
else {
const range = ModelRange._createFromPositionAndShift(opB.sourcePosition, opB.howMany);
if (opA.splitPosition.hasSameParentAs(opB.sourcePosition) && range.containsPosition(opA.splitPosition)) {
// TODO: Potential bug -- we are saving offset value directly and it is not later updated during OT.
// TODO: This may cause a bug it here was an non-undone operation that may have impacted this offset.
// TODO: Similar error was with MarkerOperation relations, where full path was saved and never updated.
const howMany = range.end.offset - opA.splitPosition.offset;
const offset = opA.splitPosition.offset - range.start.offset;
this._setRelation(opA, opB, { howMany, offset });
}
}
}
}
else if (opA instanceof MergeOperation) {
if (opB instanceof MergeOperation) {
if (!opA.targetPosition.isEqual(opB.sourcePosition)) {
this._setRelation(opA, opB, 'mergeTargetNotMoved');
}
if (opA.sourcePosition.isEqual(opB.targetPosition)) {
this._setRelation(opA, opB, 'mergeSourceNotMoved');
}
if (opA.sourcePosition.isEqual(opB.sourcePosition)) {
this._setRelation(opA, opB, 'mergeSameElement');
}
}
else if (opB instanceof SplitOperation) {
if (opA.sourcePosition.isEqual(opB.splitPosition)) {
this._setRelation(opA, opB, 'splitAtSource');
}
}
else if (opB instanceof MoveOperation && opB.howMany > 0) {
if (opA.sourcePosition.isEqual(opB.sourcePosition.getShiftedBy(opB.howMany))) {
this._setRelation(opA, opB, 'mergeSourceAffected');
}
if (opA.targetPosition.isEqual(opB.sourcePosition)) {
this._setRelation(opA, opB, 'mergeTargetWasBefore');
}
}
}
else if (opA instanceof MarkerOperation) {
const markerRange = opA.newRange;
if (!markerRange) {
return;
}
if (opB instanceof MergeOperation) {
const wasInLeftElement = markerRange.start.isEqual(opB.targetPosition);
const wasStartBeforeMergedElement = markerRange.start.isEqual(opB.deletionPosition);
const wasEndBeforeMergedElement = markerRange.end.isEqual(opB.deletionPosition);
const wasInRightElement = markerRange.end.isEqual(opB.sourcePosition);
if (wasInLeftElement || wasStartBeforeMergedElement || wasEndBeforeMergedElement || wasInRightElement) {
this._setRelation(opA, opB, {
wasInLeftElement,
wasStartBeforeMergedElement,
wasEndBeforeMergedElement,
wasInRightElement
});
}
}
}
}
/**
* Evaluates and returns contextual information about two given operations `opA` and `opB` which are about to be transformed.
*/
getContext(opA, opB, aIsStrong) {
return {
aIsStrong,
aWasUndone: this._wasUndone(opA),
bWasUndone: this._wasUndone(opB),
abRelation: this._useRelations ? this._getRelation(opA, opB) : null,
baRelation: this._useRelations ? this._getRelation(opB, opA) : null,
forceWeakRemove: this._forceWeakRemove
};
}
/**
* Returns whether given operation `op` has already been undone.
*
* Information whether an operation was undone gives more context when making a decision when two operations are in conflict.
*/
_wasUndone(op) {
// For `op`, get its original operation. After all, if `op` is a clone (or even transformed clone) of another
// operation, literally `op` couldn't be undone. It was just generated. If anything, it was the operation it origins
// from which was undone. So get that original operation.
const originalOp = this.originalOperations.get(op);
// And check with the document if the original operation was undone.
return originalOp.wasUndone || this._history.isUndoneOperation(originalOp);
}
/**
* Returns a relation between `opA` and an operation which is undone by `opB`. This can be `String` value if a relation
* was set earlier or `null` if there was no relation between those operations.
*
* This is a little tricky to understand, so let's compare it to `ContextFactory#_wasUndone`.
*
* When `wasUndone( opB )` is used, we check if the `opB` has already been undone. It is obvious, that the
* undoing operation must happen after the undone operation. So, essentially, we have `opB`, we take document history,
* we look forward in the future and ask if in that future `opB` was undone.
*
* Relations is a backward process to `wasUndone()`.
*
* Long story short - using relations is asking what happened in the past. Looking back. This time we have an undoing
* operation `opB` which has undone some other operation. When there is a transformation `opA` x `opB` and there is
* a conflict to solve and `opB` is an undoing operation, we can look back in the history and see what was a relation
* between `opA` and the operation which `opB` undone. Basing on that relation from the past, we can now make
* a better decision when resolving a conflict between two operations, because we know more about the context of
* those two operations.
*
* This is why this function does not return a relation directly between `opA` and `opB` because we need to look
* back to search for a meaningful contextual information.
*/
_getRelation(opA, opB) {
// Get the original operation. Similarly as in `wasUndone()` it is used as an universal identifier for stored data.
const origB = this.originalOperations.get(opB);
const undoneB = this._history.getUndoneOperation(origB);
// If `opB` is not undoing any operation, there is no relation.
if (!undoneB) {
return null;
}
const origA = this.originalOperations.get(opA);
const relationsA = this._relations.get(origA);
// Get all relations for `opA`, and check if there is a relation with `opB`-undone-counterpart. If so, return it.
if (relationsA) {
return relationsA.get(undoneB) || null;
}
return null;
}
/**
* Helper function for `ContextFactory#updateRelations`.
*/
_setRelation(opA, opB, relation) {
// As always, setting is for original operations, not the clones/transformed operations.
const origA = this.originalOperations.get(opA);
const origB = this.originalOperations.get(opB);
let relationsA = this._relations.get(origA);
if (!relationsA) {
relationsA = new Map();
this._relations.set(origA, relationsA);
}
relationsA.set(origB, relation);
}
}
/**
* An utility function that updates {@link module:engine/model/operation/operation~Operation#baseVersion base versions}
* of passed operations.
*
* The function simply sets `baseVersion` as a base version of the first passed operation and then increments it for
* each following operation in `operations`.
*
* @param operations Operations to update.
* @param baseVersion Base version to set for the first operation in `operations`.
*/
function updateBaseVersions(operations, baseVersion) {
for (const operation of operations) {
operation.baseVersion = baseVersion++;
}
}
/**
* Adds `howMany` instances of {@link module:engine/model/operation/nooperation~NoOperation} to `operations` set.
*/
function padWithNoOps(operations, howMany) {
for (let i = 0; i < howMany; i++) {
operations.push(new NoOperation(0));
}
}
/**
* Transformed operations set may include marker operations which were broken into multiple marker operations during transformation.
* It represents marker range being broken into multiple pieces as the transformation was processed. Each partial marker operation is
* a piece of the original marker range.
*
* These partial marker operations ("marker range pieces") should be "glued" together if, after transformations, the ranges ended up
* next to each other.
*
* If the ranges did not end up next to each other, then partial marker operations should be discarded, as the marker range cannot
* be broken into two pieces.
*
* There is always one "reference" marker operation (the original operation) and there may be some partial marker operations. Partial
* marker operations have base version set to `-1`. If the `operations` set includes partial marker operations, then they are always
* after the original marker operation.
*
* See also `MarkerOperation` x `MoveOperation` transformation.
* See also https://github.com/ckeditor/ckeditor5/pull/17071.
*/
function handlePartialMarkerOperations(operations) {
const markerOps = new Map();
for (let i = 0; i < operations.length; i++) {
const op = operations[i];
if (!(op instanceof MarkerOperation)) {
continue;
}
if (op.baseVersion !== -1) {
markerOps.set(op.name, {
op,
ranges: op.newRange ? [op.newRange] : []
});
}
else {
if (op.newRange) {
// `markerOps.get( op.name )` must exist because original marker operation is always before partial marker operations.
// If the original marker operation was changed to `NoOperation`, then the partial marker operations would be changed
// to `NoOperation` as well, so this is not a case.
markerOps.get(op.name).ranges.push(op.newRange);
}
operations.splice(i, 1);
i--;
}
}
for (const { op, ranges } of markerOps.values()) {
if (ranges.length) {
op.newRange = ModelRange._createFromRanges(ranges);
}
else {
op.newRange = null;
}
}
}
// -----------------------
setTransformation(AttributeOperation, AttributeOperation, (a, b, context) => {
// If operations in conflict, check if their ranges intersect and manage them properly.
//
// Operations can be in conflict only if:
//
// * their key is the same (they change the same attribute), and
// * they are in the same parent (operations for ranges [ 1 ] - [ 3 ] and [ 2, 0 ] - [ 2, 5 ] change different
// elements and can't be in conflict).
if (a.key === b.key && a.range.start.hasSameParentAs(b.range.start)) {
// First, we want to apply change to the part of a range that has not been changed by the other operation.
const operations = a.range.getDifference(b.range).map(range => {
return new AttributeOperation(range, a.key, a.oldValue, a.newValue, 0);
});
// Then we take care of the common part of ranges.
const common = a.range.getIntersection(b.range);
if (common) {
// If this operation is more important, we also want to apply change to the part of the
// original range that has already been changed by the other operation. Since that range
// got changed we also have to update `oldValue`.
if (context.aIsStrong) {
operations.push(new AttributeOperation(common, b.key, b.newValue, a.newValue, 0));
}
}
if (operations.length == 0) {
return [new NoOperation(0)];
}
return operations;
}
else {
// If operations don't conflict, simply return an array containing just a clone of this operation.
return [a];
}
});
setTransformation(AttributeOperation, InsertOperation, (a, b) => {
// Case 1:
//
// The attribute operation range includes the position where nodes were inserted.
// There are two possible scenarios: the inserted nodes were text and they should receive attributes or
// the inserted nodes were elements and they should not receive attributes.
//
if (a.range.start.hasSameParentAs(b.position) && a.range.containsPosition(b.position)) {
// If new nodes should not receive attributes, two separated ranges will be returned.
// Otherwise, one expanded range will be returned.
const range = a.range._getTransformedByInsertion(b.position, b.howMany, !b.shouldReceiveAttributes);
const result = range.map(r => {
return new AttributeOperation(r, a.key, a.oldValue, a.newValue, a.baseVersion);
});
if (b.shouldReceiveAttributes) {
// `AttributeOperation#range` includes some newly inserted text.
// The operation should also change the attribute of that text. An example:
//
// Bold should be applied on the following range:
// <p>Fo[zb]ar</p>
//
// In meantime, new text is typed:
// <p>Fozxxbar</p>
//
// Bold should be applied also on the new text:
// <p>Fo[zxxb]ar</p>
// <p>Fo<$text bold="true">zxxb</$text>ar</p>
//
// There is a special case to consider here to consider.
//
// Consider setting an attribute with multiple possible values, for example `highlight`. The inserted text might
// have already an attribute value applied and the `oldValue` property of the attribute operation might be wrong:
//
// Attribute `highlight="yellow"` should be applied on the following range:
// <p>Fo[zb]ar<p>
//
// In meantime, character `x` with `highlight="red"` is typed:
// <p>Fo[z<$text highlight="red">x</$text>b]ar</p>
//
// In this case we cannot simply apply operation changing the attribute value from `null` to `"yellow"` for the whole range
// because that would lead to an exception (`oldValue` is incorrect for `x`).
//
// We also cannot break the original range as this would mess up a scenario when there are multiple following
// insert operations, because then only the first inserted character is included in those ranges:
// <p>Fo[z][x][b]ar</p> --> <p>Fo[z][x]x[b]ar</p> --> <p>Fo[z][x]xx[b]ar</p>
//
// So, the attribute range needs be expanded, no matter what attributes are set on the inserted nodes:
//
// <p>Fo[z<$text highlight="red">x</$text>b]ar</p> <--- Change from `null` to `yellow`, throwing an exception.
//
// But before that operation would be applied, we will add an additional attribute operation that will change
// attributes on the inserted nodes in a way which would make the original operation correct:
//
// <p>Fo[z{<$text highlight="red">}x</$text>b]ar</p> <--- Change range `{}` from `red` to `null`.
// <p>Fo[zxb]ar</p> <--- Now change from `null` to `yellow` is completely fine.
//
// Generate complementary attribute operation. Be sure to add it before the original operation.
const op = _getComplementaryAttributeOperations(b, a.key, a.oldValue);
if (op) {
result.unshift(op);
}
}
// If nodes should not receive new attribute, we are done here.
return result;
}
// If insert operation is not expanding the attribute operation range, simply transform the range.
a.range = a.range._getTransformedByInsertion(b.position, b.howMany, false)[0];
return [a];
});
/**
* Helper function for `AttributeOperation` x `InsertOperation` (and reverse) transformation.
*
* For given `insertOperation` it checks the inserted node if it has an attribute `key` set to a value different
* than `newValue`. If so, it generates an `AttributeOperation` which changes the value of `key` attribute to `newValue`.
*/
function _getComplementaryAttributeOperations(insertOperation, key, newValue) {
const nodes = insertOperation.nodes;
// At the beginning we store the attribute value from the first node.
const insertValue = nodes.getNode(0).getAttribute(key);
if (insertValue == newValue) {
return null;
}
const range = new ModelRange(insertOperation.position, insertOperation.position.getShiftedBy(insertOperation.howMany));
return new AttributeOperation(range, key, insertValue, newValue, 0);
}
setTransformation(AttributeOperation, MergeOperation, (a, b) => {
const ranges = [];
// Case 1:
//
// Attribute change on the merged element. In this case, the merged element was moved to the graveyard.
// An additional attribute operation that will change the (re)moved element needs to be generated.
//
if (a.range.start.hasSameParentAs(b.deletionPosition)) {
if (a.range.containsPosition(b.deletionPosition) || a.range.start.isEqual(b.deletionPosition)) {
ranges.push(ModelRange._createFromPositionAndShift(b.graveyardPosition, 1));
}
}
const range = a.range._getTransformedByMergeOperation(b);
// Do not add empty (collapsed) ranges to the result. `range` may be collapsed if it contained only the merged element.
if (!range.isCollapsed) {
ranges.push(range);
}
// Create `AttributeOperation`s out of the ranges.
return ranges.map(range => {
return new AttributeOperation(range, a.key, a.oldValue, a.newValue, a.baseVersion);
});
});
setTransformation(AttributeOperation, MoveOperation, (a, b) => {
const ranges = _breakRangeByMoveOperation(a.range, b);
// Create `AttributeOperation`s out of the ranges.
return ranges.map(range => new AttributeOperation(range, a.key, a.oldValue, a.newValue, a.baseVersion));
});
/**
* Helper function for `AttributeOperation` x `MoveOperation` transformation.
*
* Takes the passed `range` and transforms it by move operation `moveOp` in a specific way. Only top-level nodes of `range`
* are considered to be in the range. If move operation moves nodes deep from inside of the range, those nodes won't
* be included in the result. In other words, top-level nodes of the ranges from the result are exactly the same as
* top-level nodes of the original `range`.
*
* This is important for `AttributeOperation` because, for its range, it changes only the top-level nodes. So we need to
* track only how those nodes have been affected by `MoveOperation`.
*/
function _breakRangeByMoveOperation(range, moveOp) {
const moveRange = ModelRange._createFromPositionAndShift(moveOp.sourcePosition, moveOp.howMany);
// We are transforming `range` (original range) by `moveRange` (range moved by move operation). As usual when it comes to
// transforming a ranges, we may have a common part of the ranges and we may have a difference part (zero to two ranges).
let common = null;
let difference = [];
// Let's compare the ranges.
if (moveRange.containsRange(range, true)) {
// If the whole original range is moved, treat it whole as a common part. There's also no difference part.
common = range;
}
else if (range.start.hasSameParentAs(moveRange.start)) {
// If the ranges are "on the same level" (in the same parent) then move operation may move exactly those nodes
// that are changed by the attribute operation. In this case we get common part and difference part in the usual way.
difference = range.getDifference(moveRange);
common = range.getIntersection(moveRange);
}
else {
// In any other situation we assume that original range is different than move range, that is that move operation
// moves other nodes that attribute operation change. Even if the moved range is deep inside in the original range.
//
// Note that this is different than in `.getIntersection` (we would get a common part in that case) and different
// than `.getDifference` (we would get two ranges).
difference = [range];
}
const result = [];
// The default behaviour of `_getTransformedByMove` might get wrong results for difference part, though, so
// we do it by hand.
for (let diff of difference) {
// First, transform the range by removing moved nodes. Since this is a difference, this is safe, `null` won't be returned
// as the range is different than the moved range.
diff = diff._getTransformedByDeletion(moveOp.sourcePosition, moveOp.howMany);
// Transform also `targetPosition`.
const targetPosition = moveOp.getMovedRangeStart();
// Spread the range only if moved nodes are inserted only between the top-level nodes of the `diff` range.
const spread = diff.start.hasSameParentAs(targetPosition);
// Transform by insertion of moved nodes.
const diffs = diff._getTransformedByInsertion(targetPosition, moveOp.howMany, spread);
result.push(...diffs);
}
// Common part can be simply transformed by the move operation. This is because move operation will not target to
// that common part (the operation would have to target inside its own moved range).
if (common) {
result.push(common._getTransformedByMove(moveOp.sourcePosition, moveOp.targetPosition, moveOp.howMany, false)[0]);
}
return result;
}
setTransformation(AttributeOperation, SplitOperation, (a, b) => {
// Case 1:
//
// Split node is the last node in `AttributeOperation#range`.
// `AttributeOperation#range` needs to be expanded to include the new (split) node.
//
// Attribute `type` to be changed to `numbered` but the `listItem` is split.
// <listItem type="bulleted">foobar</listItem>
//
// After split:
// <listItem type="bulleted">foo</listItem><listItem type="bulleted">bar</listItem>
//
// After attribute change:
// <listItem type="numbered">foo</listItem><listItem type="numbered">foo</listItem>
//
if (a.range.end.isEqual(b.insertionPosition)) {
if (!b.graveyardPosition) {
a.range.end.offset++;
}
return [a];
}
// Case 2:
//
// Split position is inside `AttributeOperation#range`, at the same level, so the nodes to change are
// not going to make a flat range.
//
// Content with range-to-change and split position:
// <p>Fo[zb^a]r</p>
//
// After split:
// <p>Fozb</p><p>ar</p>
//
// Make two separate ranges containing all nodes to change:
// <p>Fo[zb]</p><p>[a]r</p>
//
if (a.range.start.hasSameParentAs(b.splitPosition) && a.range.containsPosition(b.splitPosition)) {
const secondPart = a.clone();
secondPart.range = new ModelRange(b.moveTargetPosition.clone(), a.range.end._getCombined(b.splitPosition, b.moveTargetPosition));
a.range.end = b.splitPosition.clone();
a.range.end.stickiness = 'toPrevious';
return [a, secondPart];
}
// The default case.
//
a.range = a.range._getTransformedBySplitOperation(b);
return [a];
});
setTransformation(InsertOperation, AttributeOperation, (a, b) => {
const result = [a];
// Case 1:
//
// The attribute operation range includes the position where nodes were inserted.
// There are two possible scenarios: the inserted nodes were text and they should receive attributes or
// the inserted nodes were elements and they should not receive attributes.
//
// This is a mirror scenario to the one described in `AttributeOperation` x `InsertOperation` transformation,
// although this case is a little less complicated. In this case we simply need to change attributes of the
// inserted nodes and that's it.
//
if (a.shouldReceiveAttributes && a.position.hasSameParentAs(b.range.start) && b.range.containsPosition(a.position)) {
const op = _getComplementaryAttributeOperations(a, b.key, b.newValue);
if (op) {
result.push(op);
}
}
// The default case is: do nothing.
// `AttributeOperation` does not change the model tree structure so `InsertOperation` does not need to be changed.
//
return result;
});
setTransformation(InsertOperation, InsertOperation, (a, b, context) => {
// Case 1:
//
// Two insert operations insert nodes at the same position. Since they are the same, it needs to be decided
// what will be the order of inserted nodes. However, there is no additional information to help in that
// decision. Also, when `b` will be transformed by `a`, the same order must be maintained.
//
// To achieve that, we will check if the operation is strong.
// If it is, it