/*!
* VisualEditor DataModel Change class.
*
* @copyright See AUTHORS.txt
*/
/**
* DataModel change.
*
* A change is a list of transactions to be applied sequentially on top of a certain history
* state, together with a set that includes all new store values (annotations and DOM elements)
* introduced by those transactions.
*
* It can be thought of more abstractly as a function f: D1 -> D2 a document in a
* specific start state D1, modifying parts of the document to produce a specific end state D2.
*
* For two changes f: D1 -> D2 and g: D2 -> D3 we define f.concat(g): D1 -> D3 as the change
* obtained by applying f then g. By associativity of functions,
* a.concat(b.concat(c)) = a.concat(b).concat(c) for any consecutive changes a, b, c. Writing
*
* x * y := x.concat(y) ,
*
* we have a * (b * c) = (a * b) * c, so we can just write either as a * b * c.
*
* For a change f: D1 -> D2 we define f.reversed() as the change D2 -> D1 such that
* f.concat(f.reversed()) is the identity change D1 -> D1. Writing
*
* inv(x) := x.reversed() ,
*
* we have f * inv(f) = the identity change on D1 .
*
* Given two changes f: D1 -> D2 and g: D1 -> D3 , We would like to define f.rebasedOnto(g)
* ("f rebased onto g") as a change that maps D3 onto some D4: conceptually, it is f
* modified so it can be applied after g. This is a useful concept because it allows changes
* written in parallel to be sequenced into a linear order. However, for some changes there
* is no reasonable way to do this; e.g. when f and g both change the same word to something
* different. In this case we make f.rebasedOnto(g) return null and we say it conflicts.
*
* Given f: D1 -> D2 , g: D2 -> D3, and x: D1 -> D4, we give three guarantees about rebasing:
*
* 1. x.rebasedOnto(f) conflicts if and only if f.rebasedOnto(x) conflicts.
* 2. If there is no conflict, f.concat(x.rebasedOnto(f)) equals x.concat(f.rebasedOnto(x)).
* 3. If there is no conflict, x.rebasedOnto(f).rebasedOnto(g) equals x.rebasedOnto(f * g).
*
* We can consider a conflicting transaction starting at some document D to be 0: D->null,
* and regard any two conflicting transactions starting at D to be equal, and just write 0
* where D1 is clear from context. Then, writing
*
* x|y := x.rebasedOnto(y),
*
* we can write our guarantees. Given f: D1 -> D2 , g: D2 -> D3, and x: D1 -> D4:
*
* 1. Change conflict well definedness: x|f = 0 if and only if f|x = 0.
* 2. Change commutativity: f * g|f equals g * f|g .
* 3. Rebasing piecewise: if (x|f)|g != 0, then (x|f)|g equals x|(f * g) .
*
* These guarantees let us reorder non-conflicting changes without affecting the resulting
* document. They also let us move in the inverse direction ("rebase under"), from sequential
* changes to parallel ones, for if f: D1 -> D2 and g: D2 -> D3, then g|inv(f)
* maps from D1 to some D4, and conceptually it is g modified to apply without f having been
* applied.
*
* Note that rebasing piecewise is *not* equivalent for changes that conflict: if a change
* conflicts f, it might not conflict with f*g. For example, if x|f = 0 then
*
* (x|f)|inv(f) = 0 but x|(f * inv(f)) = x.
*
* @class
* @constructor
* @param {number} [start] Length of the history stack at change start
* @param {ve.dm.Transaction[]} [transactions] Transactions to apply
* @param {ve.dm.HashValueStore[]} [stores] For each transaction, a collection of new store items
* @param {Object} [selections] For each author ID (key), latest ve.dm.Selection
*/
ve.dm.Change = function VeDmChange( start, transactions, stores, selections ) {
this.start = start || 0;
this.transactions = transactions || [];
this.store = new ve.dm.HashValueStore();
this.storeLengthAtTransaction = [];
if ( stores ) {
stores.forEach( ( store ) => {
this.store.merge( store );
this.storeLengthAtTransaction.push( this.store.getLength() );
} );
}
this.selections = selections || {};
};
/* Static methods */
ve.dm.Change.static = {};
/**
* Deserialize a change from a JSONable object
*
* Store values can be deserialized, or kept verbatim; the latter is an optimization if the
* Change object will be rebased and reserialized without ever being applied to a document.
*
* @param {Object} data Change serialized as a JSONable object
* @param {boolean} [preserveStoreValues] Keep store values verbatim instead of deserializing
* @param {boolean} [unsafe] Use unsafe deserialization (skipping DOMPurify), used via #unsafeDeserialize
* @return {ve.dm.Change} Deserialized change
*/
ve.dm.Change.static.deserialize = function ( data, preserveStoreValues, unsafe ) {
const hasOwn = Object.prototype.hasOwnProperty,
getTransactionInfo = this.getTransactionInfo,
deserializeValue = this.deserializeValue,
selections = {},
transactions = [],
// If stores is undefined, create an array of nulls
stores = data.stores || data.transactions.map( () => null );
/**
* Apply annotations in-place to array of code units
*
* @param {string[]} items Array of code units
* @param {string[]|null} annotations Annotations to apply uniformly, or null
*/
function annotate( items, annotations ) {
if ( !annotations || !annotations.length ) {
return;
}
for ( let j = 0, jLen = items.length; j < jLen; j++ ) {
items[ j ] = [ items[ j ], annotations.slice() ];
}
}
for ( const authorId in data.selections ) {
selections[ authorId ] = ve.dm.Selection.static.newFromJSON(
data.selections[ authorId ]
);
}
const deserializeStore = ve.dm.HashValueStore.static.deserialize.bind(
null,
preserveStoreValues ? ( x ) => x : ( x ) => deserializeValue( x, unsafe )
);
let prevInfo;
for ( let i = 0, iLen = data.transactions.length; i < iLen; i++ ) {
const txSerialized = data.transactions[ i ];
let tx;
if ( typeof txSerialized === 'string' ) {
const insertion = txSerialized.split( '' );
annotate(
insertion,
prevInfo.uniformInsert && prevInfo.uniformInsert.annotations
);
tx = new ve.dm.Transaction( [
{ type: 'retain', length: prevInfo.end },
{ type: 'replace', remove: [], insert: insertion },
{ type: 'retain', length: prevInfo.docLength - prevInfo.end }
], prevInfo.authorId );
if ( tx.operations[ 2 ].length === 0 ) {
tx.operations.pop();
}
} else {
tx = ve.dm.Transaction.static.deserialize( txSerialized );
if ( prevInfo && !hasOwn.call( txSerialized, 'authorId' ) ) {
tx.authorId = prevInfo.authorId;
}
}
transactions.push( tx );
prevInfo = getTransactionInfo( tx );
}
return new ve.dm.Change(
data.start,
transactions,
stores.map( deserializeStore ),
selections
);
};
/**
* Deserialize a change from a JSONable object without sanitizing DOM nodes
*
* @param {Object} data
* @return {ve.dm.Change} Deserialized change
*/
ve.dm.Change.static.unsafeDeserialize = function ( data ) {
return this.deserialize( data, false, true );
};
ve.dm.Change.static.serializeValue = function ( value ) {
if ( value instanceof ve.dm.Annotation ) {
return { type: 'annotation', value: value.element };
} else if ( Array.isArray( value ) && value[ 0 ] instanceof Node ) {
return { type: 'domNodes', value: value.map( ve.getNodeHtml ).join( '' ) };
} else {
return { type: 'plain', value: value };
}
};
ve.dm.Change.static.deserializeValue = function ( serialized, unsafe ) {
if ( serialized.type === 'annotation' ) {
return ve.dm.annotationFactory.createFromElement( serialized.value );
} else if ( serialized.type === 'domNodes' ) {
if ( unsafe ) {
// We can use jQuery here because unsafe sanitization
// only happens in browser clients.
// eslint-disable-next-line no-undef
return $.parseHTML( serialized.value, undefined, true );
} else {
// Convert NodeList to Array
return Array.prototype.slice.call( ve.sanitizeHtml( serialized.value ) );
}
} else if ( serialized.type === 'plain' ) {
return serialized.value;
} else {
throw new Error( 'Unrecognized type: ' + serialized.type );
}
};
/**
* Rebase parallel transactions transactionA and transactionB onto each other
*
* Recalling that a transaction is a mapping from one ve.dm.ElementLinearData state to another,
* suppose we have two parallel transactions, i.e.:
*
* - transactionA mapping docstate0 to some docstateA, and
* - transactionB mapping docstate0 to some docstateB .
*
* Then we want rebasing to give us two new transactions:
*
* - aRebasedOntoB mapping docstateB to some docstateC, and
* - bRebasedOntoA mapping docstateA to docstateC ,
*
* so that applying transactionA then bRebasedOntoA results in the same document state as
* applying transactionB then aRebasedOntoB .
*
* However, it is useful to regard some transaction pairs as "conflicting" or unrebasable. In
* this implementation, transactions are considered to conflict if they have active ranges that
* overlap, where a transaction's "active range" means the smallest single range in the *start*
* document outside which the contents are unchanged by the transaction. (In practice the
* operations within the transaction actually specify which ranges map to where, giving a
* natural and unambiguous definition of "active range". Also, the identity transaction on a
* document state has no active range but is trivially rebasable with any parallel
* transaction).
*
* For non-conflicting transactions, rebasing of each transaction is performed by resizing the
* inactive range either before or after the transaction to accommodate the length difference
* caused by the other transaction. There is ambiguity in the case where both transactions have
* a zero-length active range at the same position (i.e. two inserts in the same place); in this
* case, transactionA's insertion is put before transactionB's.
*
* It is impossible for rebasing defined this way to create an invalid transaction that breaks
* tree validity. This is clear because every position in the rebased transaction's active
* range has the same node ancestry as the corresponding position before the rebase (else a
* tag must have changed both before and after that position, contradicting the fact that the
* transactions' active ranges do not overlap).
*
* Also it is clear that for a pair of non-conflicting parallel transactions, applying either
* one followed by the other rebased will result in the same final document state, as required.
*
* @param {ve.dm.Transaction} transactionA Transaction A
* @param {ve.dm.Transaction} transactionB Transaction B, with the same document start state
* @return {any[]} [ aRebasedOntoB, bRebasedOntoA ], or [ null, null ] if conflicting
*/
ve.dm.Change.static.rebaseTransactions = function ( transactionA, transactionB ) {
transactionA = transactionA.clone();
transactionB = transactionB.clone();
const infoA = transactionA.getActiveRangeAndLengthDiff();
const infoB = transactionB.getActiveRangeAndLengthDiff();
if ( infoA.start === undefined || infoB.start === undefined ) {
// One of the transactions is a no-op: only need to adjust its retain length.
// We can safely adjust both, because the no-op must have diff 0
transactionA.adjustRetain( 'start', infoB.diff );
transactionB.adjustRetain( 'start', infoA.diff );
} else if ( infoA.end <= infoB.start ) {
// This includes the case where both transactions are insertions at the same
// point
transactionB.adjustRetain( 'start', infoA.diff );
transactionA.adjustRetain( 'end', infoB.diff );
} else if ( infoB.end <= infoA.start ) {
transactionA.adjustRetain( 'start', infoB.diff );
transactionB.adjustRetain( 'end', infoA.diff );
} else {
// The active ranges overlap: conflict
return [ null, null ];
}
return [ transactionA, transactionB ];
};
/**
* @typedef {Object} RebasedChange
* @memberof ve.dm.Change
* @property {ve.dm.Change} rebased Rebase onto history of uncommitted (or an initial segment of it)
* @property {ve.dm.Change} transposedHistory Rebase of history onto initial segment of uncommitted
* @property {ve.dm.Change|null} rejected Unrebasable final segment of uncommitted
*/
/**
* Rebase a change on top of a parallel committed one
*
* Since a change is a stack of transactions, we define change rebasing in terms of transaction
* rebasing. We require transaction rebasing to meet the three guarantees described above for
* change rebasing. To be precise, given any transactions a:D1->D2, b:D2->D3 and x:D1->D4, we
* require that:
*
* 1. Transaction conflict well definedness: a|x = 0 if and only if x|a = 0.
* 2. Transaction commutativity: a * x|a equals x * a|x .
* 3. Rebasing piecewise: if (x|a)|b != 0, then (x|a)|b equals x|(a * b) .
*
* Given committed history consisting of transactions a1,a2,…,aN, and an uncommitted update
* consisting of transactions b1,b2,…,bM, our approach is to rebase the whole list a1,…,aN
* over b1, and at the same time rebase b1 onto a1*…*aN.
* Then we repeat the process for b2, and so on. To rebase a1,…,aN over b1, the following
* approach would work:
*
* a1' := a1|b1
* a2' := a2|(inv(a1) * b1 * a1')
* a3' := a3|(inv(a2) * inv(a1) * b1 * a1' * a2')
* ⋮
*
* That is, rebase a_i under a_i-1,…,a_1, then over b1,…,bM, then over a'1,…,a_i-1' .
*
* However, because of the way transactions are written, it's not actually easy to implement
* transaction concatenation, so we would want to calculate a2' as piecewise rebases
*
* a2' = ((a2|inv(a1))|b1)|a1'
*
* which is unsatisfactory because a2|inv(a1) may well conflict even if a2|(inv(a1) * b1 * a1')
* as a whole would not conflict (e.g. if b1 modifies only parts of the document distant from a1
* and a2).
*
* So observe that by transaction commutivity we can rewrite a2' as:
*
* a2' := a2|(inv(a1) * a1 * b1|a1)
* = a2|(b1|a1)
*
* and that b1|a1 conflicts only if a1|b1 conflicts (so this introduces no new conflicts). In
* general we can write:
*
* a1' := a1|b1
* b1' := b1|a1
* a2' := a2|b1'
* b1'' := b1'|a2
* a3' := a3|b1''
* b1''' := a1''|a3
*
* Continuing in this way, we obtain a1',…,aN' rebased over b1, and b1''''''' (N primes)
* rebased onto a1 * … * aN . Iteratively we can take the same approach to rebase over
* b2,…,bM, giving both rebased lists as required.
*
* If any of the transaction rebases conflict, then we rebase the largest possible
* non-conflicting initial segment b1,…,bK onto all of a1,…,aN (so clearly K < M).
*
* If there are two parallel inserts at the same location, then ordering is ambiguous. We
* resolve this by putting the insert for the transaction with the highest author ID
* first (Javascript less-than is used, so comparisons with a null author ID do not fail).
* If the author IDs are the same, then A's insertion is put before B's.
*
* @param {ve.dm.Change} history Committed history
* @param {ve.dm.Change} uncommitted New transactions, with same start as history
* @return {ve.dm.Change.RebasedChange}
*/
ve.dm.Change.static.rebaseUncommittedChange = function ( history, uncommitted ) {
if ( history.start !== uncommitted.start ) {
throw new Error( 'Different starts: ' + history.start + ' and ' + uncommitted.start );
}
let transactionsA = history.transactions.slice();
const transactionsB = uncommitted.transactions.slice();
let storesA = history.getStores();
const storesB = uncommitted.getStores();
const selectionsA = ve.cloneObject( history.selections );
let selectionsB = ve.cloneObject( uncommitted.selections );
let rejected = null;
// For each element b_i of transactionsB, rebase the whole list transactionsA over b_i.
// To rebase a1, a2, a3, …, aN over b_i, first we rebase a1 onto b_i. Then we rebase
// a2 onto some b', defined as
//
// b_i' := b_i|a1 , that is b_i.rebasedOnto(a1)
//
// (which as proven above is equivalent to inv(a1) * b_i * a1)
//
// Similarly we rebase a3 onto b_i'' := b_i'|a2, and so on.
//
// The rebased a_j are used for the transposed history: they will all get rebased over the
// rest of transactionsB in the same way.
// The fully rebased b_i forms the i'th element of the rebased transactionsB.
//
// If any rebase b_i|a_j fails, we stop rebasing at b_i (i.e. finishing with b_{i-1}).
// We return
// - rebased: (uncommitted sliced up to i) rebased onto history
// - transposedHistory: history rebased onto (uncommitted sliced up to i)
// - rejected: uncommitted sliced from i onwards
bLoop:
for ( let i = 0, iLen = transactionsB.length; i < iLen; i++ ) {
let b = transactionsB[ i ];
let storeB = storesB[ i ];
const rebasedTransactionsA = [];
const rebasedStoresA = [];
for ( let j = 0, jLen = transactionsA.length; j < jLen; j++ ) {
const a = transactionsA[ j ];
const storeA = storesA[ j ];
let rebases;
if ( b.authorId < a.authorId ) {
rebases = ve.dm.Change.static.rebaseTransactions( b, a ).reverse();
} else {
rebases = ve.dm.Change.static.rebaseTransactions( a, b );
}
if ( rebases[ 0 ] === null ) {
rejected = uncommitted.mostRecent( uncommitted.start + i );
transactionsB.length = i;
storesB.length = i;
selectionsB = {};
break bLoop;
}
rebasedTransactionsA[ j ] = rebases[ 0 ];
rebasedStoresA[ j ] = storeA.difference( storeB );
b = rebases[ 1 ];
storeB = storeB.difference( storeA );
}
transactionsA = rebasedTransactionsA;
storesA = rebasedStoresA;
transactionsB[ i ] = b;
storesB[ i ] = storeB;
}
// Length calculations below assume no removal of empty rebased transactions
const rebased = new ve.dm.Change(
uncommitted.start + transactionsA.length,
transactionsB,
storesB,
{}
);
const transposedHistory = new ve.dm.Change(
history.start + transactionsB.length,
transactionsA,
storesA,
{}
);
let authorId;
for ( authorId in selectionsB ) {
authorId = +authorId;
rebased.selections[ authorId ] = selectionsB[ authorId ].translateByChange( transposedHistory, authorId );
}
for ( authorId in selectionsA ) {
authorId = +authorId;
transposedHistory.selections[ authorId ] = selectionsA[ authorId ].translateByChange( rebased, authorId );
}
return {
rebased: rebased,
transposedHistory: transposedHistory,
rejected: rejected
};
};
/**
* @typedef UniformTextInfo
* @memberof ve.dm.Change
* @property {string} text The code units, in a single string
* @property {string} annotations Annotation hashes for all text
* @property {string} annotationString Comma-separated annotation hashes
*/
/**
* @typedef TransactionInfo
* @memberof ve.dm.Change
* @property {number} start The start offset of the replacement
* @property {number} end The end offset of the replacement (after replacement)
* @property {number} docLength The total length of the document (after replacement)
* @property {number} authorId The author ID
* @property {ve.dm.Change.UniformTextInfo|null} uniformInsert The insertion as uniform text, or null if not
*/
/**
* Get info about a transaction if it is a "simple replacement", or null if not
*
* A simple replacement transaction is one that has just one retain op
*
* @param {ve.dm.Transaction} tx The transaction
* @return {ve.dm.Change.TransactionInfo|null} Info about the transaction if a simple replacement, else null
*/
ve.dm.Change.static.getTransactionInfo = function ( tx ) {
// Copy of ve.dm.ElementLinearData.static.getAnnotationHashesFromItem, but we
// don't want to load all of ElementLinearData and its dependencies on the server-side.
function getAnnotations( item ) {
if ( typeof item === 'string' ) {
return [];
} else if ( item.annotations ) {
return item.annotations.slice();
} else if ( item[ 1 ] ) {
return item[ 1 ].slice();
} else {
return [];
}
}
/**
* Get an item's single code unit (without annotation), or null if not a code unit
*
* @param {Object|Array|string} item The item
* @return {string|null} The single code unit, or null if not a code unit
*/
function getSingleCodeUnit( item ) {
if ( typeof item === 'string' && item.length === 1 ) {
return item;
}
if ( Array.isArray( item ) && item[ 0 ].length === 1 ) {
return item[ 0 ];
}
return null;
}
/**
* Get info about the "uniform text" from an item array, or null if not uniform text
*
* The item array is uniform text if all items have the same annotations, and
* every item is a single code unit of text
*
* @param {Array} items The items
* @return {ve.dm.Change.UniformTextInfo|null} Info about the uniform text, or null if not uniform text
*/
function getUniformText( items ) {
const codeUnits = [];
if ( items.length === 0 ) {
return null;
}
let codeUnit = getSingleCodeUnit( items[ 0 ] );
if ( codeUnit === null ) {
return null;
}
codeUnits.push( codeUnit );
const annotations = getAnnotations( items[ 0 ] );
const annotationString = annotations.join( ',' );
for ( let i = 1, iLen = items.length; i < iLen; i++ ) {
codeUnit = getSingleCodeUnit( items[ i ] );
if ( codeUnit === null ) {
return null;
}
codeUnits.push( codeUnit );
if ( annotationString !== getAnnotations( items[ i ] ).join( ',' ) ) {
return null;
}
}
return {
text: codeUnits.join( '' ),
annotations: annotations,
annotationString: annotationString
};
}
const op0 = tx.operations[ 0 ];
const op1 = tx.operations[ 1 ];
const op2 = tx.operations[ 2 ];
let replaceOp, start, end, docLength;
if (
op0 &&
op0.type === 'replace' &&
( !op1 || op1.type === 'retain' ) &&
!op2
) {
replaceOp = op0;
start = 0;
end = start + replaceOp.insert.length;
docLength = end;
} else if (
op0 &&
op0.type === 'retain' &&
op1 &&
op1.type === 'replace' &&
( !op2 || op2.type === 'retain' )
) {
replaceOp = op1;
start = op0.length;
end = start + replaceOp.insert.length;
docLength = end + ( op2 ? op2.length : 0 );
} else {
return null;
}
return {
start: start,
end: end,
docLength: docLength,
authorId: tx.authorId,
uniformInsert: getUniformText( replaceOp.insert )
};
};
/* Methods */
/**
* Create a clone of this Change
*
* @return {ve.dm.Change} Clone of this change
*/
ve.dm.Change.prototype.clone = function () {
return this.constructor.static.unsafeDeserialize( this.toJSON() );
};
/**
* @return {boolean} True if this change has no transactions or selections
*/
ve.dm.Change.prototype.isEmpty = function () {
return this.transactions.length === 0 && Object.keys( this.selections ).length === 0;
};
/**
* @return {number} The number of transactions
*/
ve.dm.Change.prototype.getLength = function () {
return this.transactions.length;
};
/**
* Get the store items introduced by transaction n
*
* @param {number} n The index of a transaction within the change
* @return {ve.dm.HashValueStore} The store items introduced by transaction n
*/
ve.dm.Change.prototype.getStore = function ( n ) {
return this.store.slice(
n > 0 ? this.storeLengthAtTransaction[ n - 1 ] : 0,
this.storeLengthAtTransaction[ n ]
);
};
/**
* Get the stores for each transaction
*
* @return {ve.dm.HashValueStore[]} Each transaction's store items (shallow copied store)
*/
ve.dm.Change.prototype.getStores = function () {
const stores = [];
let start = 0;
for ( let i = 0, len = this.getLength(); i < len; i++ ) {
const end = this.storeLengthAtTransaction[ i ];
stores.push( this.store.slice( start, end ) );
start = end;
}
return stores;
};
/**
* @return {number|null} The first author in a transaction or selection change, or null if empty
*/
ve.dm.Change.prototype.firstAuthorId = function () {
if ( this.transactions.length ) {
return this.transactions[ 0 ].authorId;
}
const authors = Object.keys( this.selections );
if ( authors.length ) {
return +authors[ 0 ];
}
return null;
};
/**
* Get a human-readable summary of the change
*
* @return {string} Human-readable summary
*/
ve.dm.Change.prototype.summarize = function () {
return '{ start: ' + this.start + ', txs: [ ' +
this.transactions.map( ( tx ) => tx.summarize() ).join( ', ' ) + ' ] }';
};
/**
* Get the change that backs out this change.
*
* Note that applying it will not revert start or remove stored items
*
* @return {ve.dm.Change} The change that backs out this change
*/
ve.dm.Change.prototype.reversed = function () {
return new ve.dm.Change(
this.start + this.transactions.length,
this.transactions.map( ( tx ) => ve.dm.Transaction.prototype.reversed.call( tx ) ).reverse(),
// Empty store for each transaction (reverting cannot possibly add new annotations)
this.transactions.map( () => new ve.dm.HashValueStore() ),
{}
);
};
/**
* Rebase this change onto other (ready to apply on top of other)
*
* @param {ve.dm.Change} other Other change
* @return {ve.dm.Change|null} Rebased change applicable on top of other, or null if rebasing fails
* @throws {Error} If this change and other have different starts
*/
ve.dm.Change.prototype.rebasedOnto = function ( other ) {
const rebases = this.constructor.static.rebaseUncommittedChange( other, this );
return rebases.rejected ? null : rebases.rebased;
};
/**
* Build a composite change from two consecutive changes
*
* @param {ve.dm.Change} other Change that starts immediately after this
* @return {ve.dm.Change} Composite change
* @throws {Error} If other does not start immediately after this
*/
ve.dm.Change.prototype.concat = function ( other ) {
if ( other.start !== this.start + this.transactions.length ) {
throw new Error( 'this ends at ' + ( this.start + this.transactions.length ) +
' but other starts at ' + other.start );
}
return new ve.dm.Change(
this.start,
this.transactions.concat( other.transactions ),
this.getStores().concat( other.getStores() ),
other.selections
);
};
/**
* Push a transaction, after having grown the hash value store if required
*
* @param {ve.dm.Transaction} transaction The transaction
* @param {number} storeLength The corresponding store length required
*/
ve.dm.Change.prototype.pushTransaction = function ( transaction, storeLength ) {
if ( typeof storeLength !== 'number' ) {
throw new Error( 'Expected numerical storeLength argument, not ' + storeLength );
}
this.transactions.push( transaction );
this.storeLengthAtTransaction.push( storeLength );
};
/**
* Push another change onto this change
*
* @param {ve.dm.Change} other Change that starts immediately after this
* @throws {Error} If other does not start immediately after this
*/
ve.dm.Change.prototype.push = function ( other ) {
if ( other.start !== this.start + this.getLength() ) {
throw new Error( 'this ends at ' + ( this.start + this.getLength() ) +
' but other starts at ' + other.start );
}
const stores = other.getStores();
for ( let i = 0, iLen = other.transactions.length; i < iLen; i++ ) {
const transaction = other.transactions[ i ];
const store = stores[ i ];
this.store.merge( store );
this.pushTransaction( transaction, this.store.getLength() );
}
this.selections = ve.cloneObject( other.selections );
};
/**
* Build a composite change from two parallel changes
*
* @param {ve.dm.Change} other Change parallel to this
* @return {ve.dm.Change} Composite change
* @throws {Error} If this change and other have different starts
*/
ve.dm.Change.prototype.concatRebased = function ( other ) {
return this.concat( other.rebasedOnto( this ) );
};
/**
* Build a change from the last (most recent) transactions
*
* @param {number} start Start offset
* @return {ve.dm.Change} Subset of this change with only the most recent transactions
*/
ve.dm.Change.prototype.mostRecent = function ( start ) {
if ( arguments.length > 1 ) {
throw new Error( 'storeStart is no longer needed' );
}
return new ve.dm.Change(
start,
this.transactions.slice( start - this.start ),
this.getStores().slice( start - this.start ),
ve.cloneObject( this.selections )
);
};
/**
* Build a change from the first (least recent) transactions of this change.
*
* Always removes selections.
*
* @param {number} length Number of transactions
* @return {ve.dm.Change} Subset of this change with only the least recent transactions
*/
ve.dm.Change.prototype.truncate = function ( length ) {
if ( arguments.length > 1 ) {
throw new Error( 'storeLength is no longer needed' );
}
return new ve.dm.Change(
this.start,
this.transactions.slice( 0, length ),
this.getStores().slice( 0, length ),
{}
);
};
/**
* Apply change to surface
*
* @param {ve.dm.Surface} surface Surface in change start state
* @param {boolean} [applySelection] Apply a selection based on the modified range
*/
ve.dm.Change.prototype.applyTo = function ( surface, applySelection ) {
const doc = surface.getDocument();
if ( this.start !== doc.completeHistory.getLength() ) {
throw new Error( 'Change starts at ' + this.start + ', but doc is at ' + doc.completeHistory.getLength() );
}
this.getStores().forEach( ( store ) => {
doc.store.merge( store );
} );
// Isolate other users' changes from ours with a breakpoint
surface.breakpoint();
this.transactions.forEach( ( tx ) => {
surface.change( tx );
// Don't mark as applied: this.start already tracks this
tx.applied = false;
// TODO: This would be better fixed by T202730
if ( applySelection ) {
const range = tx.getModifiedRange( doc );
// If the transaction only touched the internal list, there is no modified range within the main document
if ( range ) {
const offset = doc.getNearestCursorOffset( range.end, -1 );
if ( offset !== -1 ) {
surface.setSelection( new ve.dm.LinearSelection( new ve.Range( offset ) ) );
}
}
}
} );
surface.breakpoint();
};
/**
* Unapply change to surface, including truncating history and store
*
* @param {ve.dm.Surface} surface Surface in change end state
*/
ve.dm.Change.prototype.unapplyTo = function ( surface ) {
const doc = surface.getDocument(),
historyLength = doc.completeHistory.getLength() - this.getLength();
if ( this.start !== historyLength ) {
throw new Error( 'Invalid start: change starts at ' + this.start + ', but doc would be at ' + historyLength );
}
this.transactions.slice().reverse().forEach( ( tx ) => {
surface.change( tx.reversed() );
} );
doc.completeHistory.transactions.length = historyLength;
doc.completeHistory.storeLengthAtTransaction.length = historyLength;
doc.store.truncate( doc.completeHistory.storeLengthAtTransaction[ historyLength - 1 ] );
};
/**
* Append change transactions to history
*
* @param {ve.dm.Document} documentModel
* @throws {Error} If this change does not start at the top of the history
*/
ve.dm.Change.prototype.addToHistory = function ( documentModel ) {
documentModel.completeHistory.push( this );
};
/**
* Remove change transactions from history
*
* @param {ve.dm.Document} doc
* @throws {Error} If this change does not end at the top of the history
*/
ve.dm.Change.prototype.removeFromHistory = function ( doc ) {
if ( this.start + this.getLength() !== doc.completeHistory.getLength() ) {
throw new Error( 'this ends at ' + ( this.start + this.getLength() ) +
' but history ends at ' + doc.completeHistory.getLength() );
}
doc.completeHistory.transactions.length -= this.transactions.length;
doc.completeHistory.storeLengthAtTransaction.length -= this.transactions.length;
doc.store.truncate( doc.completeHistory.storeLengthAtTransaction[ doc.completeHistory.getLength() - 1 ] );
};
/**
* Serialize the change to a JSONable object
*
* Store values can be serialized, or kept verbatim (which only makes sense if they are serialized
* already, i.e. the Change object was created by #deserialize without deserializing store values).
*
* @param {boolean} [preserveStoreValues] If true, keep store values verbatim instead of serializing
* @return {Object} JSONable object
*/
ve.dm.Change.prototype.serialize = function ( preserveStoreValues ) {
const getTransactionInfo = this.constructor.static.getTransactionInfo,
selections = {},
transactions = [];
// Recursively serialize, so this method is the inverse of deserialize
// without having to use JSON.stringify (which is also recursive).
for ( const authorId in this.selections ) {
selections[ authorId ] = this.selections[ authorId ].toJSON();
}
const serializeStoreValues = preserveStoreValues ? function noop( x ) {
return x;
} : this.constructor.static.serializeValue;
const serializeStore = function ( store ) {
return store.serialize( serializeStoreValues );
};
let prevInfo;
for ( let i = 0, iLen = this.transactions.length; i < iLen; i++ ) {
const tx = this.transactions[ i ];
const info = getTransactionInfo( tx );
if (
info &&
prevInfo &&
info.authorId === prevInfo.authorId &&
info.start === prevInfo.end &&
info.uniformInsert &&
prevInfo.uniformInsert &&
info.uniformInsert.annotationString === prevInfo.uniformInsert.annotationString
) {
transactions.push( info.uniformInsert.text );
} else {
const txSerialized = tx.toJSON();
if ( i > 0 && tx.authorId === this.transactions[ i - 1 ].authorId ) {
delete txSerialized.authorId;
}
transactions.push( txSerialized );
}
prevInfo = info;
}
const stores = this.getStores().map( serializeStore );
const data = {
start: this.start,
transactions: transactions
};
// Only set stores if at least one is non-null
if ( stores.some( ( store ) => store !== null ) ) {
data.stores = stores;
}
if ( Object.keys( selections ).length ) {
data.selections = selections;
}
return data;
};
/**
* Called automatically by JSON.stringify, see #serialize.
*
* @param {string} [key] Key in parent object
* @return {Object} JSONable object
*/
ve.dm.Change.prototype.toJSON = function () {
// Ensure no native arguments are passed through to #serialize.
return this.serialize();
};
/**
* Get a Change with all this Change's Transactions compacted into one (or zero)
*
* The Change has the same effect when applied as this Change does, but it may cause
* rebase conflicts where this change does not.
*
* TODO: introduce a "histLength" feature so the new change can be considered as
* having length > 1.
*
* @return {ve.dm.Change} One-Transaction version of this Change (or empty change)
*/
ve.dm.Change.prototype.squash = function () {
if ( this.transactions.length <= 1 ) {
return this.clone();
}
return new ve.dm.Change(
this.start,
[ ve.dm.TransactionSquasher.static.squash( this.transactions ) ],
[ this.store.slice() ],
// Shallow clone (the individual selections are immutable so need no cloning)
ve.cloneObject( this.selections )
);
};