| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905 |
- // @ts-check
- /**
- * The text operation from OT.js with some minor cosmetic changes.
- *
- * Specifically, this is based on
- * https://github.com/Operational-Transformation/ot.js/
- * blob/298825f58fb51fefb352e7df5ddbc668f4d5646f/lib/text-operation.js
- * from 18 Mar 2013.
- */
- 'use strict'
- const EditOperation = require('./edit_operation')
- const {
- RetainOp,
- InsertOp,
- RemoveOp,
- isRetain,
- isInsert,
- isRemove,
- } = require('./scan_op')
- const {
- UnprocessableError,
- ApplyError,
- InvalidInsertionError,
- TooLongError,
- } = require('../errors')
- const Range = require('../range')
- const ClearTrackingProps = require('../file_data/clear_tracking_props')
- const TrackingProps = require('../file_data/tracking_props')
- /**
- * @import StringFileData from '../file_data/string_file_data'
- * @import { RawTextOperation, TrackingDirective } from '../types'
- * @import { ScanOp } from '../operation/scan_op'
- * @import TrackedChangeList from '../file_data/tracked_change_list'
- *
- * @typedef {{tracking?: TrackingProps, commentIds?: string[]}} InsertOptions
- */
- /**
- * Create an empty text operation.
- * @extends EditOperation
- */
- class TextOperation extends EditOperation {
- /**
- * Length of the longest file that we'll attempt to edit, in characters.
- *
- * @type {number}
- */
- static MAX_STRING_LENGTH = 2 * Math.pow(1024, 2)
- static UnprocessableError = UnprocessableError
- static ApplyError = ApplyError
- static InvalidInsertionError = InvalidInsertionError
- static TooLongError = TooLongError
- constructor() {
- super()
- /**
- * When an operation is applied to an input string, you can think of this as
- * if an imaginary cursor runs over the entire string and skips over some
- * parts, removes some parts and inserts characters at some positions. These
- * actions (skip/remove/insert) are stored as an array in the "ops" property.
- * @type {ScanOp[]}
- */
- this.ops = []
- /**
- * An operation's baseLength is the length of every string the operation
- * can be applied to.
- */
- this.baseLength = 0
- /**
- * The targetLength is the length of every string that results from applying
- * the operation on a valid input string.
- */
- this.targetLength = 0
- /**
- * The expected content hash after this operation is applied
- *
- * @type {string | null}
- */
- this.contentHash = null
- }
- /**
- * @param {TextOperation} other
- * @return {boolean}
- */
- equals(other) {
- if (this.baseLength !== other.baseLength) {
- return false
- }
- if (this.targetLength !== other.targetLength) {
- return false
- }
- if (this.ops.length !== other.ops.length) {
- return false
- }
- for (let i = 0; i < this.ops.length; i++) {
- if (!this.ops[i].equals(other.ops[i])) {
- return false
- }
- }
- return true
- }
- // After an operation is constructed, the user of the library can specify the
- // actions of an operation (skip/insert/remove) with these three builder
- // methods. They all return the operation for convenient chaining.
- /**
- * Skip over a given number of characters.
- * @param {number | {r: number}} n
- * @param {{tracking?: TrackingDirective}} opts
- * @returns {TextOperation}
- */
- retain(n, opts = {}) {
- if (n === 0) {
- return this
- }
- if (!isRetain(n)) {
- throw new Error('retain expects an integer or a retain object')
- }
- const newOp = RetainOp.fromJSON(n)
- newOp.tracking = opts.tracking
- if (newOp.length === 0) {
- return this
- }
- this.baseLength += newOp.length
- this.targetLength += newOp.length
- const lastOperation = this.ops[this.ops.length - 1]
- if (lastOperation?.canMergeWith(newOp)) {
- // The last op is a retain op => we can merge them into one op.
- lastOperation.mergeWith(newOp)
- } else {
- // Create a new op.
- this.ops.push(newOp)
- }
- return this
- }
- /**
- * Insert a string at the current position.
- * @param {string | {i: string}} insertValue
- * @param {InsertOptions} opts
- * @returns {TextOperation}
- */
- insert(insertValue, opts = {}) {
- if (!isInsert(insertValue)) {
- throw new Error('insert expects a string or an insert object')
- }
- const newOp = InsertOp.fromJSON(insertValue)
- newOp.tracking = opts.tracking
- newOp.commentIds = opts.commentIds
- if (newOp.insertion === '') {
- return this
- }
- this.targetLength += newOp.insertion.length
- const ops = this.ops
- const lastOp = this.ops[this.ops.length - 1]
- if (lastOp?.canMergeWith(newOp)) {
- // Merge insert op.
- lastOp.mergeWith(newOp)
- } else if (lastOp instanceof RemoveOp) {
- // It doesn't matter when an operation is applied whether the operation
- // is remove(3), insert("something") or insert("something"), remove(3).
- // Here we enforce that in this case, the insert op always comes first.
- // This makes all operations that have the same effect when applied to
- // a document of the right length equal in respect to the `equals` method.
- const secondToLastOp = ops[ops.length - 2]
- if (secondToLastOp?.canMergeWith(newOp)) {
- secondToLastOp.mergeWith(newOp)
- } else {
- ops[ops.length] = ops[ops.length - 1]
- ops[ops.length - 2] = newOp
- }
- } else {
- ops.push(newOp)
- }
- return this
- }
- /**
- * Remove a string at the current position.
- * @param {number | string} n
- * @returns {TextOperation}
- */
- remove(n) {
- if (typeof n === 'string') {
- n = n.length
- }
- if (typeof n !== 'number') {
- throw new Error('remove expects an integer or a string')
- }
- if (n === 0) {
- return this
- }
- if (n > 0) {
- n = -n
- }
- const newOp = RemoveOp.fromJSON(n)
- this.baseLength -= n
- const lastOp = this.ops[this.ops.length - 1]
- if (lastOp?.canMergeWith(newOp)) {
- lastOp.mergeWith(newOp)
- } else {
- this.ops.push(newOp)
- }
- return this
- }
- /**
- * Tests whether this operation has no effect.
- */
- isNoop() {
- return (
- this.ops.length === 0 ||
- (this.ops.length === 1 && this.ops[0] instanceof RetainOp)
- )
- }
- /**
- * Pretty printing.
- */
- toString() {
- return this.ops.map(op => op.toString()).join(', ')
- }
- /**
- * @inheritdoc
- * @returns {RawTextOperation}
- */
- toJSON() {
- /** @type {RawTextOperation} */
- const json = { textOperation: this.ops.map(op => op.toJSON()) }
- if (this.contentHash != null) {
- json.contentHash = this.contentHash
- }
- return json
- }
- /**
- * Converts a plain JS object into an operation and validates it.
- * @param {RawTextOperation} obj
- * @returns {TextOperation}
- */
- static fromJSON = function ({ textOperation: ops, contentHash }) {
- const o = new TextOperation()
- for (const op of ops) {
- if (isRetain(op)) {
- const retain = RetainOp.fromJSON(op)
- o.retain(retain.length, { tracking: retain.tracking })
- } else if (isInsert(op)) {
- const insert = InsertOp.fromJSON(op)
- o.insert(insert.insertion, {
- commentIds: insert.commentIds,
- tracking: insert.tracking,
- })
- } else if (isRemove(op)) {
- const remove = RemoveOp.fromJSON(op)
- o.remove(-remove.length)
- } else {
- throw new UnprocessableError('unknown operation: ' + JSON.stringify(op))
- }
- }
- if (contentHash != null) {
- o.contentHash = contentHash
- }
- return o
- }
- /**
- * Apply an operation to a string, returning a new string. Throws an error if
- * there's a mismatch between the input string and the operation.
- * @override
- * @inheritdoc
- * @param {StringFileData} file
- */
- apply(file) {
- const str = file.getContent()
- const operation = this
- if (str.length !== operation.baseLength) {
- throw new TextOperation.ApplyError(
- "The operation's base length must be equal to the string's length.",
- operation,
- str
- )
- }
- const ops = this.ops
- let inputCursor = 0
- let result = ''
- for (const op of ops) {
- if (op instanceof RetainOp) {
- if (inputCursor + op.length > str.length) {
- throw new ApplyError(
- "Operation can't retain more chars than are left in the string.",
- op.toJSON(),
- str
- )
- }
- result += str.slice(inputCursor, inputCursor + op.length)
- inputCursor += op.length
- } else if (op instanceof InsertOp) {
- file.comments.applyInsert(
- new Range(result.length, op.insertion.length),
- { commentIds: op.commentIds }
- )
- result += op.insertion
- } else if (op instanceof RemoveOp) {
- file.comments.applyDelete(new Range(result.length, op.length))
- inputCursor += op.length
- } else {
- throw new UnprocessableError('Unknown ScanOp type during apply')
- }
- }
- if (inputCursor !== str.length) {
- throw new TextOperation.ApplyError(
- "The operation didn't operate on the whole string.",
- operation,
- str
- )
- }
- if (result.length > TextOperation.MAX_STRING_LENGTH) {
- throw new TextOperation.TooLongError(operation, result.length)
- }
- file.trackedChanges.applyTextOperation(this)
- file.content = result
- }
- /**
- * @inheritdoc
- * @param {number} length of the original string; non-negative
- * @return {number} length of the new string; non-negative
- */
- applyToLength(length) {
- const operation = this
- if (length !== operation.baseLength) {
- throw new TextOperation.ApplyError(
- "The operation's base length must be equal to the string's length.",
- operation,
- length
- )
- }
- const { length: newLength, inputCursor } = this.ops.reduce(
- (intermediate, op) => op.applyToLength(intermediate),
- { length: 0, inputCursor: 0, inputLength: length }
- )
- if (inputCursor !== length) {
- throw new TextOperation.ApplyError(
- "The operation didn't operate on the whole string.",
- operation,
- length
- )
- }
- if (newLength > TextOperation.MAX_STRING_LENGTH) {
- throw new TextOperation.TooLongError(operation, newLength)
- }
- return newLength
- }
- /**
- * @inheritdoc
- * @param {StringFileData} previousState
- */
- invert(previousState) {
- const str = previousState.getContent()
- let strIndex = 0
- const inverse = new TextOperation()
- const ops = this.ops
- for (let i = 0, l = ops.length; i < l; i++) {
- const op = ops[i]
- if (op instanceof RetainOp) {
- if (op.tracking) {
- // Where we need to end up after the retains
- const target = strIndex + op.length
- // A previous retain could have overriden some tracking info. Now we
- // need to restore it.
- const previousChanges = previousState.trackedChanges.intersectRange(
- new Range(strIndex, op.length)
- )
- for (const change of previousChanges) {
- if (strIndex < change.range.start) {
- inverse.retain(change.range.start - strIndex, {
- tracking: new ClearTrackingProps(),
- })
- strIndex = change.range.start
- }
- inverse.retain(change.range.length, {
- tracking: change.tracking,
- })
- strIndex += change.range.length
- }
- if (strIndex < target) {
- inverse.retain(target - strIndex, {
- tracking: new ClearTrackingProps(),
- })
- strIndex = target
- }
- } else {
- inverse.retain(op.length)
- strIndex += op.length
- }
- } else if (op instanceof InsertOp) {
- inverse.remove(op.insertion.length)
- } else if (op instanceof RemoveOp) {
- const segments = calculateTrackingCommentSegments(
- strIndex,
- op.length,
- previousState.comments,
- previousState.trackedChanges
- )
- for (const segment of segments) {
- inverse.insert(str.slice(strIndex, strIndex + segment.length), {
- tracking: segment.tracking,
- commentIds: segment.commentIds,
- })
- strIndex += segment.length
- }
- } else {
- throw new UnprocessableError('unknown scanop during inversion')
- }
- }
- return inverse
- }
- /**
- * @inheritdoc
- * @param {EditOperation} other
- */
- canBeComposedWithForUndo(other) {
- if (!(other instanceof TextOperation)) {
- return false
- }
- if (this.isNoop() || other.isNoop()) {
- return true
- }
- const startA = getStartIndex(this)
- const startB = getStartIndex(other)
- const simpleA = getSimpleOp(this)
- const simpleB = getSimpleOp(other)
- if (!simpleA || !simpleB) {
- return false
- }
- if (simpleA instanceof InsertOp && simpleB instanceof InsertOp) {
- return startA + simpleA.insertion.length === startB
- }
- if (simpleA instanceof RemoveOp && simpleB instanceof RemoveOp) {
- // there are two possibilities to delete: with backspace and with the
- // delete key.
- return startB + simpleB.length === startA || startA === startB
- }
- return false
- }
- /**
- * @inheritdoc
- * @param {EditOperation} other
- */
- canBeComposedWith(other) {
- if (!(other instanceof TextOperation)) {
- return false
- }
- return this.targetLength === other.baseLength
- }
- /**
- * @inheritdoc
- * @param {EditOperation} operation2
- */
- compose(operation2) {
- if (!(operation2 instanceof TextOperation)) {
- throw new Error(
- `Trying to compose TextOperation with ${operation2?.constructor?.name}.`
- )
- }
- const operation1 = this
- if (operation1.targetLength !== operation2.baseLength) {
- throw new Error(
- 'The base length of the second operation has to be the ' +
- 'target length of the first operation'
- )
- }
- const operation = new TextOperation() // the combined operation
- const ops1 = operation1.ops
- const ops2 = operation2.ops // for fast access
- let i1 = 0
- let i2 = 0 // current index into ops1 respectively ops2
- let op1 = ops1[i1++]
- let op2 = ops2[i2++] // current ops
- for (;;) {
- // Dispatch on the type of op1 and op2
- if (typeof op1 === 'undefined' && typeof op2 === 'undefined') {
- // end condition: both ops1 and ops2 have been processed
- break
- }
- if (op1 instanceof RemoveOp) {
- operation.remove(-op1.length)
- op1 = ops1[i1++]
- continue
- }
- if (op2 instanceof InsertOp) {
- operation.insert(op2.insertion, {
- tracking: op2.tracking,
- commentIds: op2.commentIds,
- })
- op2 = ops2[i2++]
- continue
- }
- if (typeof op1 === 'undefined') {
- throw new Error(
- 'Cannot compose operations: first operation is too short.'
- )
- }
- if (typeof op2 === 'undefined') {
- throw new Error(
- 'Cannot compose operations: first operation is too long.'
- )
- }
- if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
- // If both have tracking info, use the latter one. Otherwise use the
- // tracking info from the former.
- const tracking = op2.tracking ?? op1.tracking
- if (op1.length > op2.length) {
- operation.retain(op2.length, {
- tracking,
- })
- op1 = new RetainOp(op1.length - op2.length, op1.tracking)
- op2 = ops2[i2++]
- } else if (op1.length === op2.length) {
- operation.retain(op1.length, {
- tracking,
- })
- op1 = ops1[i1++]
- op2 = ops2[i2++]
- } else {
- operation.retain(op1.length, {
- tracking,
- })
- op2 = new RetainOp(op2.length - op1.length, op2.tracking)
- op1 = ops1[i1++]
- }
- } else if (op1 instanceof InsertOp && op2 instanceof RemoveOp) {
- if (op1.insertion.length > op2.length) {
- op1 = new InsertOp(
- op1.insertion.slice(op2.length),
- op1.tracking,
- op1.commentIds
- )
- op2 = ops2[i2++]
- } else if (op1.insertion.length === op2.length) {
- op1 = ops1[i1++]
- op2 = ops2[i2++]
- } else {
- op2 = RemoveOp.fromJSON(op1.insertion.length - op2.length)
- op1 = ops1[i1++]
- }
- } else if (op1 instanceof InsertOp && op2 instanceof RetainOp) {
- /** @type InsertOptions */
- const opts = {
- commentIds: op1.commentIds,
- }
- if (op2.tracking instanceof TrackingProps) {
- // Prefer the tracking info on the second operation
- opts.tracking = op2.tracking
- } else if (!(op2.tracking instanceof ClearTrackingProps)) {
- // The second operation does not cancel the first operation's tracking
- opts.tracking = op1.tracking
- }
- if (op1.insertion.length > op2.length) {
- operation.insert(op1.insertion.slice(0, op2.length), opts)
- op1 = new InsertOp(
- op1.insertion.slice(op2.length),
- op1.tracking,
- op1.commentIds
- )
- op2 = ops2[i2++]
- } else if (op1.insertion.length === op2.length) {
- operation.insert(op1.insertion, opts)
- op1 = ops1[i1++]
- op2 = ops2[i2++]
- } else {
- operation.insert(op1.insertion, opts)
- op2 = new RetainOp(op2.length - op1.insertion.length, op2.tracking)
- op1 = ops1[i1++]
- }
- } else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
- if (op1.length > op2.length) {
- operation.remove(-op2.length)
- op1 = new RetainOp(op1.length - op2.length, op1.tracking)
- op2 = ops2[i2++]
- } else if (op1.length === op2.length) {
- operation.remove(-op2.length)
- op1 = ops1[i1++]
- op2 = ops2[i2++]
- } else {
- operation.remove(op1.length)
- op2 = RemoveOp.fromJSON(op1.length - op2.length)
- op1 = ops1[i1++]
- }
- } else {
- throw new Error(
- "This shouldn't happen: op1: " +
- JSON.stringify(op1) +
- ', op2: ' +
- JSON.stringify(op2)
- )
- }
- }
- return operation
- }
- /**
- * Transform takes two operations A and B that happened concurrently and
- * produces two operations A' and B' (in an array) such that
- * `apply(apply(S, A), B') = apply(apply(S, B), A')`. This function is the
- * heart of OT.
- * @param {TextOperation} operation1
- * @param {TextOperation} operation2
- * @returns {[TextOperation, TextOperation]}
- */
- static transform(operation1, operation2) {
- if (operation1.baseLength !== operation2.baseLength) {
- throw new Error('Both operations have to have the same base length')
- }
- const operation1prime = new TextOperation()
- const operation2prime = new TextOperation()
- const ops1 = operation1.ops
- const ops2 = operation2.ops
- let i1 = 0
- let i2 = 0
- let op1 = ops1[i1++]
- let op2 = ops2[i2++]
- for (;;) {
- // At every iteration of the loop, the imaginary cursor that both
- // operation1 and operation2 have that operates on the input string must
- // have the same position in the input string.
- if (typeof op1 === 'undefined' && typeof op2 === 'undefined') {
- // end condition: both ops1 and ops2 have been processed
- break
- }
- // next two cases: one or both ops are insert ops
- // => insert the string in the corresponding prime operation, skip it in
- // the other one. If both op1 and op2 are insert ops, prefer op1.
- if (op1 instanceof InsertOp) {
- operation1prime.insert(op1.insertion, {
- tracking: op1.tracking,
- commentIds: op1.commentIds,
- })
- operation2prime.retain(op1.insertion.length)
- op1 = ops1[i1++]
- continue
- }
- if (op2 instanceof InsertOp) {
- operation1prime.retain(op2.insertion.length)
- operation2prime.insert(op2.insertion, {
- tracking: op2.tracking,
- commentIds: op2.commentIds,
- })
- op2 = ops2[i2++]
- continue
- }
- if (typeof op1 === 'undefined') {
- throw new Error(
- 'Cannot compose operations: first operation is too short.'
- )
- }
- if (typeof op2 === 'undefined') {
- throw new Error(
- 'Cannot compose operations: first operation is too long.'
- )
- }
- let minl
- if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
- // Simple case: retain/retain
- // If both have tracking info, we use the one from op1
- /** @type {TrackingProps | ClearTrackingProps | undefined} */
- let operation1primeTracking
- /** @type {TrackingProps | ClearTrackingProps | undefined} */
- let operation2primeTracking
- if (op1.tracking) {
- operation1primeTracking = op1.tracking
- } else {
- operation2primeTracking = op2.tracking
- }
- if (op1.length > op2.length) {
- minl = op2.length
- op1 = new RetainOp(op1.length - op2.length, op1.tracking)
- op2 = ops2[i2++]
- } else if (op1.length === op2.length) {
- minl = op2.length
- op1 = ops1[i1++]
- op2 = ops2[i2++]
- } else {
- minl = op1.length
- op2 = new RetainOp(op2.length - op1.length, op2.tracking)
- op1 = ops1[i1++]
- }
- operation1prime.retain(minl, { tracking: operation1primeTracking })
- operation2prime.retain(minl, { tracking: operation2primeTracking })
- } else if (op1 instanceof RemoveOp && op2 instanceof RemoveOp) {
- // Both operations remove the same string at the same position. We don't
- // need to produce any operations, we just skip over the remove ops and
- // handle the case that one operation removes more than the other.
- if (op1.length > op2.length) {
- op1 = RemoveOp.fromJSON(op2.length - op1.length)
- op2 = ops2[i2++]
- } else if (op1.length === op2.length) {
- op1 = ops1[i1++]
- op2 = ops2[i2++]
- } else {
- op2 = RemoveOp.fromJSON(op1.length - op2.length)
- op1 = ops1[i1++]
- }
- // next two cases: remove/retain and retain/remove
- } else if (op1 instanceof RemoveOp && op2 instanceof RetainOp) {
- if (op1.length > op2.length) {
- minl = op2.length
- op1 = RemoveOp.fromJSON(op2.length - op1.length)
- op2 = ops2[i2++]
- } else if (op1.length === op2.length) {
- minl = op2.length
- op1 = ops1[i1++]
- op2 = ops2[i2++]
- } else {
- minl = op1.length
- op2 = new RetainOp(op2.length - op1.length, op2.tracking)
- op1 = ops1[i1++]
- }
- operation1prime.remove(minl)
- } else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
- if (op1.length > op2.length) {
- minl = op2.length
- op1 = new RetainOp(op1.length - op2.length, op1.tracking)
- op2 = ops2[i2++]
- } else if (op1.length === op2.length) {
- minl = op1.length
- op1 = ops1[i1++]
- op2 = ops2[i2++]
- } else {
- minl = op1.length
- op2 = RemoveOp.fromJSON(op1.length - op2.length)
- op1 = ops1[i1++]
- }
- operation2prime.remove(minl)
- } else {
- throw new Error("The two operations aren't compatible")
- }
- }
- return [operation1prime, operation2prime]
- }
- }
- // Operation are essentially lists of ops. There are three types of ops:
- //
- // * Retain ops: Advance the cursor position by a given number of characters.
- // Represented by positive ints.
- // * Insert ops: Insert a given string at the current cursor position.
- // Represented by strings.
- // * Remove ops: Remove the next n characters. Represented by negative ints.
- /**
- *
- * @param {TextOperation} operation
- * @returns {ScanOp | null}
- */
- function getSimpleOp(operation) {
- const ops = operation.ops
- switch (ops.length) {
- case 1:
- return ops[0]
- case 2:
- return ops[0] instanceof RetainOp
- ? ops[1]
- : ops[1] instanceof RetainOp
- ? ops[0]
- : null
- case 3:
- if (ops[0] instanceof RetainOp && ops[2] instanceof RetainOp) {
- return ops[1]
- }
- }
- return null
- }
- /**
- * @param {TextOperation} operation
- * @return {number}
- */
- function getStartIndex(operation) {
- if (operation.ops[0] instanceof RetainOp) {
- return operation.ops[0].length
- }
- return 0
- }
- /**
- * Constructs the segments defined as each overlapping range of tracked
- * changes and comments. Each segment can have it's own tracking props and
- * attached comment ids.
- *
- * The quick brown fox jumps over the lazy dog
- * Tracked inserts ---------- -----
- * Tracked deletes ------
- * Comment 1 -------
- * Comment 2 ----
- * Comment 3 -----------------
- *
- * Approx. boundaries: | | | || | | | |
- *
- * @param {number} cursor
- * @param {number} length
- * @param {import('../file_data/comment_list')} commentsList
- * @param {TrackedChangeList} trackedChangeList
- * @returns {{length: number, commentIds?: string[], tracking?: TrackingProps}[]}
- */
- function calculateTrackingCommentSegments(
- cursor,
- length,
- commentsList,
- trackedChangeList
- ) {
- const breaks = new Set()
- const opStart = cursor
- const opEnd = cursor + length
- /**
- * Utility function to limit breaks to the boundary set by the operation range
- * @param {number} rangeBoundary
- */
- function addBreak(rangeBoundary) {
- if (rangeBoundary < opStart || rangeBoundary > opEnd) {
- return
- }
- breaks.add(rangeBoundary)
- }
- // Add comment boundaries
- for (const comment of commentsList.comments.values()) {
- for (const range of comment.ranges) {
- addBreak(range.end)
- addBreak(range.start)
- }
- }
- // Add tracked change boundaries
- for (const trackedChange of trackedChangeList.asSorted()) {
- addBreak(trackedChange.range.start)
- addBreak(trackedChange.range.end)
- }
- // Add operation boundaries
- addBreak(opStart)
- addBreak(opEnd)
- // Sort the boundaries so that we can construct ranges between them
- const sortedBreaks = Array.from(breaks).sort((a, b) => a - b)
- const separateRanges = []
- for (let i = 1; i < sortedBreaks.length; i++) {
- const start = sortedBreaks[i - 1]
- const end = sortedBreaks[i]
- const currentRange = new Range(start, end - start)
- // The comment ids that cover the current range is part of this sub-range
- const commentIds = commentsList.idsCoveringRange(currentRange)
- // The tracking info that covers the current range is part of this sub-range
- const tracking = trackedChangeList.propsAtRange(currentRange)
- separateRanges.push({
- length: currentRange.length,
- commentIds: commentIds.length > 0 ? commentIds : undefined,
- tracking,
- })
- }
- return separateRanges
- }
- module.exports = TextOperation
|