text_operation.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935
  1. // @ts-check
  2. /**
  3. * The text operation from OT.js with some minor cosmetic changes.
  4. *
  5. * Specifically, this is based on
  6. * https://github.com/Operational-Transformation/ot.js/
  7. * blob/298825f58fb51fefb352e7df5ddbc668f4d5646f/lib/text-operation.js
  8. * from 18 Mar 2013.
  9. */
  10. 'use strict'
  11. const containsNonBmpChars = require('../util').containsNonBmpChars
  12. const EditOperation = require('./edit_operation')
  13. const {
  14. RetainOp,
  15. InsertOp,
  16. RemoveOp,
  17. isRetain,
  18. isInsert,
  19. isRemove,
  20. } = require('./scan_op')
  21. const {
  22. UnprocessableError,
  23. ApplyError,
  24. InvalidInsertionError,
  25. TooLongError,
  26. } = require('../errors')
  27. const Range = require('../range')
  28. const ClearTrackingProps = require('../file_data/clear_tracking_props')
  29. const TrackingProps = require('../file_data/tracking_props')
  30. /**
  31. * @typedef {import('../file_data/string_file_data')} StringFileData
  32. * @typedef {import('../types').RawTextOperation} RawTextOperation
  33. * @typedef {import('../operation/scan_op').ScanOp} ScanOp
  34. * @typedef {import('../file_data/tracked_change_list')} TrackedChangeList
  35. * @typedef {import('../types').TrackingDirective} TrackingDirective
  36. * @typedef {{tracking?: TrackingProps, commentIds?: string[]}} InsertOptions
  37. */
  38. /**
  39. * Create an empty text operation.
  40. * @extends EditOperation
  41. */
  42. class TextOperation extends EditOperation {
  43. /**
  44. * Length of the longest file that we'll attempt to edit, in characters.
  45. *
  46. * This number used to be 2M characters, but it didn't include tracked
  47. * deletes. Now that it includes tracked deletes, we raise the limit to the
  48. * Mongo document size limit. It would have been impossible to store more
  49. * tracked deletes in a single document.
  50. */
  51. static MAX_STRING_LENGTH = 16 * Math.pow(1024, 2)
  52. /**
  53. * Max editable file length, excluding tracked deletes.
  54. */
  55. static MAX_STRING_LENGTH_EXCLUDING_TRACKED_DELETES = 2 * Math.pow(1024, 2)
  56. static UnprocessableError = UnprocessableError
  57. static ApplyError = ApplyError
  58. static InvalidInsertionError = InvalidInsertionError
  59. static TooLongError = TooLongError
  60. constructor() {
  61. super()
  62. // When an operation is applied to an input string, you can think of this as
  63. // if an imaginary cursor runs over the entire string and skips over some
  64. // parts, removes some parts and inserts characters at some positions. These
  65. // actions (skip/remove/insert) are stored as an array in the "ops" property.
  66. /** @type {ScanOp[]} */
  67. this.ops = []
  68. // An operation's baseLength is the length of every string the operation
  69. // can be applied to.
  70. this.baseLength = 0
  71. // The targetLength is the length of every string that results from applying
  72. // the operation on a valid input string.
  73. this.targetLength = 0
  74. }
  75. /**
  76. * @param {TextOperation} other
  77. * @return {boolean}
  78. */
  79. equals(other) {
  80. if (this.baseLength !== other.baseLength) {
  81. return false
  82. }
  83. if (this.targetLength !== other.targetLength) {
  84. return false
  85. }
  86. if (this.ops.length !== other.ops.length) {
  87. return false
  88. }
  89. for (let i = 0; i < this.ops.length; i++) {
  90. if (!this.ops[i].equals(other.ops[i])) {
  91. return false
  92. }
  93. }
  94. return true
  95. }
  96. // After an operation is constructed, the user of the library can specify the
  97. // actions of an operation (skip/insert/remove) with these three builder
  98. // methods. They all return the operation for convenient chaining.
  99. /**
  100. * Skip over a given number of characters.
  101. * @param {number | {r: number}} n
  102. * @param {{tracking?: TrackingDirective}} opts
  103. * @returns {TextOperation}
  104. */
  105. retain(n, opts = {}) {
  106. if (n === 0) {
  107. return this
  108. }
  109. if (!isRetain(n)) {
  110. throw new Error('retain expects an integer or a retain object')
  111. }
  112. const newOp = RetainOp.fromJSON(n)
  113. newOp.tracking = opts.tracking
  114. if (newOp.length === 0) {
  115. return this
  116. }
  117. this.baseLength += newOp.length
  118. this.targetLength += newOp.length
  119. const lastOperation = this.ops[this.ops.length - 1]
  120. if (lastOperation?.canMergeWith(newOp)) {
  121. // The last op is a retain op => we can merge them into one op.
  122. lastOperation.mergeWith(newOp)
  123. } else {
  124. // Create a new op.
  125. this.ops.push(newOp)
  126. }
  127. return this
  128. }
  129. /**
  130. * Insert a string at the current position.
  131. * @param {string | {i: string}} insertValue
  132. * @param {InsertOptions} opts
  133. * @returns {TextOperation}
  134. */
  135. insert(insertValue, opts = {}) {
  136. if (!isInsert(insertValue)) {
  137. throw new Error('insert expects a string or an insert object')
  138. }
  139. const newOp = InsertOp.fromJSON(insertValue)
  140. newOp.tracking = opts.tracking
  141. newOp.commentIds = opts.commentIds
  142. if (newOp.insertion === '') {
  143. return this
  144. }
  145. this.targetLength += newOp.insertion.length
  146. const ops = this.ops
  147. const lastOp = this.ops[this.ops.length - 1]
  148. if (lastOp?.canMergeWith(newOp)) {
  149. // Merge insert op.
  150. lastOp.mergeWith(newOp)
  151. } else if (lastOp instanceof RemoveOp) {
  152. // It doesn't matter when an operation is applied whether the operation
  153. // is remove(3), insert("something") or insert("something"), remove(3).
  154. // Here we enforce that in this case, the insert op always comes first.
  155. // This makes all operations that have the same effect when applied to
  156. // a document of the right length equal in respect to the `equals` method.
  157. const secondToLastOp = ops[ops.length - 2]
  158. if (secondToLastOp?.canMergeWith(newOp)) {
  159. secondToLastOp.mergeWith(newOp)
  160. } else {
  161. ops[ops.length] = ops[ops.length - 1]
  162. ops[ops.length - 2] = newOp
  163. }
  164. } else {
  165. ops.push(newOp)
  166. }
  167. return this
  168. }
  169. /**
  170. * Remove a string at the current position.
  171. * @param {number | string} n
  172. * @returns {TextOperation}
  173. */
  174. remove(n) {
  175. if (typeof n === 'string') {
  176. n = n.length
  177. }
  178. if (typeof n !== 'number') {
  179. throw new Error('remove expects an integer or a string')
  180. }
  181. if (n === 0) {
  182. return this
  183. }
  184. if (n > 0) {
  185. n = -n
  186. }
  187. const newOp = RemoveOp.fromJSON(n)
  188. this.baseLength -= n
  189. const lastOp = this.ops[this.ops.length - 1]
  190. if (lastOp?.canMergeWith(newOp)) {
  191. lastOp.mergeWith(newOp)
  192. } else {
  193. this.ops.push(newOp)
  194. }
  195. return this
  196. }
  197. /**
  198. * Tests whether this operation has no effect.
  199. */
  200. isNoop() {
  201. return (
  202. this.ops.length === 0 ||
  203. (this.ops.length === 1 && this.ops[0] instanceof RetainOp)
  204. )
  205. }
  206. /**
  207. * Pretty printing.
  208. */
  209. toString() {
  210. return this.ops.map(op => op.toString()).join(', ')
  211. }
  212. /**
  213. * @inheritdoc
  214. * @returns {RawTextOperation}
  215. */
  216. toJSON() {
  217. return { textOperation: this.ops.map(op => op.toJSON()) }
  218. }
  219. /**
  220. * Converts a plain JS object into an operation and validates it.
  221. * @param {RawTextOperation} obj
  222. * @returns {TextOperation}
  223. */
  224. static fromJSON = function ({ textOperation: ops }) {
  225. const o = new TextOperation()
  226. for (const op of ops) {
  227. if (isRetain(op)) {
  228. const retain = RetainOp.fromJSON(op)
  229. o.retain(retain.length, { tracking: retain.tracking })
  230. } else if (isInsert(op)) {
  231. const insert = InsertOp.fromJSON(op)
  232. o.insert(insert.insertion, {
  233. commentIds: insert.commentIds,
  234. tracking: insert.tracking,
  235. })
  236. } else if (isRemove(op)) {
  237. const remove = RemoveOp.fromJSON(op)
  238. o.remove(-remove.length)
  239. } else {
  240. throw new UnprocessableError('unknown operation: ' + JSON.stringify(op))
  241. }
  242. }
  243. return o
  244. }
  245. /**
  246. * Apply an operation to a string, returning a new string. Throws an error if
  247. * there's a mismatch between the input string and the operation.
  248. * @override
  249. * @inheritdoc
  250. * @param {StringFileData} file
  251. */
  252. apply(file) {
  253. const str = file.getContent()
  254. const operation = this
  255. if (containsNonBmpChars(str)) {
  256. throw new TextOperation.ApplyError(
  257. 'The string contains non BMP characters.',
  258. operation,
  259. str
  260. )
  261. }
  262. if (str.length !== operation.baseLength) {
  263. throw new TextOperation.ApplyError(
  264. "The operation's base length must be equal to the string's length.",
  265. operation,
  266. str
  267. )
  268. }
  269. const ops = this.ops
  270. let inputCursor = 0
  271. let result = ''
  272. for (const op of ops) {
  273. if (op instanceof RetainOp) {
  274. if (inputCursor + op.length > str.length) {
  275. throw new ApplyError(
  276. "Operation can't retain more chars than are left in the string.",
  277. op.toJSON(),
  278. str
  279. )
  280. }
  281. file.trackedChanges.applyRetain(result.length, op.length, {
  282. tracking: op.tracking,
  283. })
  284. result += str.slice(inputCursor, inputCursor + op.length)
  285. inputCursor += op.length
  286. } else if (op instanceof InsertOp) {
  287. if (containsNonBmpChars(op.insertion)) {
  288. throw new InvalidInsertionError(str, op.toJSON())
  289. }
  290. file.trackedChanges.applyInsert(result.length, op.insertion, {
  291. tracking: op.tracking,
  292. })
  293. file.comments.applyInsert(
  294. new Range(result.length, op.insertion.length),
  295. { commentIds: op.commentIds }
  296. )
  297. result += op.insertion
  298. } else if (op instanceof RemoveOp) {
  299. file.trackedChanges.applyDelete(result.length, op.length)
  300. file.comments.applyDelete(new Range(result.length, op.length))
  301. inputCursor += op.length
  302. } else {
  303. throw new UnprocessableError('Unknown ScanOp type during apply')
  304. }
  305. }
  306. if (inputCursor !== str.length) {
  307. throw new TextOperation.ApplyError(
  308. "The operation didn't operate on the whole string.",
  309. operation,
  310. str
  311. )
  312. }
  313. if (result.length > TextOperation.MAX_STRING_LENGTH) {
  314. // We're over the hard limit, with or without tracked deletes
  315. throw new TextOperation.TooLongError(operation, result.length)
  316. }
  317. if (
  318. result.length > TextOperation.MAX_STRING_LENGTH_EXCLUDING_TRACKED_DELETES
  319. ) {
  320. // We might be over the limit, but we need to check how much of the
  321. // length is tracked deletes.
  322. let trackedDeletesLength = 0
  323. for (const change of file.trackedChanges) {
  324. if (change.tracking.type === 'delete') {
  325. trackedDeletesLength += change.range.length
  326. }
  327. }
  328. if (
  329. result.length - trackedDeletesLength >
  330. TextOperation.MAX_STRING_LENGTH_EXCLUDING_TRACKED_DELETES
  331. ) {
  332. throw new TextOperation.TooLongError(operation, result.length)
  333. }
  334. }
  335. file.content = result
  336. }
  337. /**
  338. * @inheritdoc
  339. * @param {number} length of the original string; non-negative
  340. * @return {number} length of the new string; non-negative
  341. */
  342. applyToLength(length) {
  343. const operation = this
  344. if (length !== operation.baseLength) {
  345. throw new TextOperation.ApplyError(
  346. "The operation's base length must be equal to the string's length.",
  347. operation,
  348. length
  349. )
  350. }
  351. const { length: newLength, inputCursor } = this.ops.reduce(
  352. (intermediate, op) => op.applyToLength(intermediate),
  353. { length: 0, inputCursor: 0, inputLength: length }
  354. )
  355. if (inputCursor !== length) {
  356. throw new TextOperation.ApplyError(
  357. "The operation didn't operate on the whole string.",
  358. operation,
  359. length
  360. )
  361. }
  362. if (newLength > TextOperation.MAX_STRING_LENGTH) {
  363. throw new TextOperation.TooLongError(operation, newLength)
  364. }
  365. return newLength
  366. }
  367. /**
  368. * @inheritdoc
  369. * @param {StringFileData} previousState
  370. */
  371. invert(previousState) {
  372. const str = previousState.getContent()
  373. let strIndex = 0
  374. const inverse = new TextOperation()
  375. const ops = this.ops
  376. for (let i = 0, l = ops.length; i < l; i++) {
  377. const op = ops[i]
  378. if (op instanceof RetainOp) {
  379. // Where we need to end up after the retains
  380. const target = strIndex + op.length
  381. // A previous retain could have overriden some tracking info. Now we
  382. // need to restore it.
  383. const previousRanges = previousState.trackedChanges.inRange(
  384. new Range(strIndex, op.length)
  385. )
  386. let removeTrackingInfoIfNeeded
  387. if (op.tracking) {
  388. removeTrackingInfoIfNeeded = new ClearTrackingProps()
  389. }
  390. for (const trackedChange of previousRanges) {
  391. if (strIndex < trackedChange.range.start) {
  392. inverse.retain(trackedChange.range.start - strIndex, {
  393. tracking: removeTrackingInfoIfNeeded,
  394. })
  395. strIndex = trackedChange.range.start
  396. }
  397. if (trackedChange.range.end < strIndex + op.length) {
  398. inverse.retain(trackedChange.range.length, {
  399. tracking: trackedChange.tracking,
  400. })
  401. strIndex = trackedChange.range.end
  402. }
  403. if (trackedChange.range.end !== strIndex) {
  404. // No need to split the range at the end
  405. const [left] = trackedChange.range.splitAt(strIndex)
  406. inverse.retain(left.length, { tracking: trackedChange.tracking })
  407. strIndex = left.end
  408. }
  409. }
  410. if (strIndex < target) {
  411. inverse.retain(target - strIndex, {
  412. tracking: removeTrackingInfoIfNeeded,
  413. })
  414. strIndex = target
  415. }
  416. } else if (op instanceof InsertOp) {
  417. inverse.remove(op.insertion.length)
  418. } else if (op instanceof RemoveOp) {
  419. const segments = calculateTrackingCommentSegments(
  420. strIndex,
  421. op.length,
  422. previousState.comments,
  423. previousState.trackedChanges
  424. )
  425. for (const segment of segments) {
  426. inverse.insert(str.slice(strIndex, strIndex + segment.length), {
  427. tracking: segment.tracking,
  428. commentIds: segment.commentIds,
  429. })
  430. strIndex += segment.length
  431. }
  432. } else {
  433. throw new UnprocessableError('unknown scanop during inversion')
  434. }
  435. }
  436. return inverse
  437. }
  438. /**
  439. * @inheritdoc
  440. * @param {EditOperation} other
  441. */
  442. canBeComposedWithForUndo(other) {
  443. if (!(other instanceof TextOperation)) {
  444. return false
  445. }
  446. if (this.isNoop() || other.isNoop()) {
  447. return true
  448. }
  449. const startA = getStartIndex(this)
  450. const startB = getStartIndex(other)
  451. const simpleA = getSimpleOp(this)
  452. const simpleB = getSimpleOp(other)
  453. if (!simpleA || !simpleB) {
  454. return false
  455. }
  456. if (simpleA instanceof InsertOp && simpleB instanceof InsertOp) {
  457. return startA + simpleA.insertion.length === startB
  458. }
  459. if (simpleA instanceof RemoveOp && simpleB instanceof RemoveOp) {
  460. // there are two possibilities to delete: with backspace and with the
  461. // delete key.
  462. return startB + simpleB.length === startA || startA === startB
  463. }
  464. return false
  465. }
  466. /**
  467. * @inheritdoc
  468. * @param {EditOperation} other
  469. */
  470. canBeComposedWith(other) {
  471. if (!(other instanceof TextOperation)) {
  472. return false
  473. }
  474. return this.targetLength === other.baseLength
  475. }
  476. /**
  477. * @inheritdoc
  478. * @param {EditOperation} operation2
  479. */
  480. compose(operation2) {
  481. if (!(operation2 instanceof TextOperation)) {
  482. throw new Error(
  483. `Trying to compose TextOperation with ${operation2?.constructor?.name}.`
  484. )
  485. }
  486. const operation1 = this
  487. if (operation1.targetLength !== operation2.baseLength) {
  488. throw new Error(
  489. 'The base length of the second operation has to be the ' +
  490. 'target length of the first operation'
  491. )
  492. }
  493. const operation = new TextOperation() // the combined operation
  494. const ops1 = operation1.ops
  495. const ops2 = operation2.ops // for fast access
  496. let i1 = 0
  497. let i2 = 0 // current index into ops1 respectively ops2
  498. let op1 = ops1[i1++]
  499. let op2 = ops2[i2++] // current ops
  500. for (;;) {
  501. // Dispatch on the type of op1 and op2
  502. if (typeof op1 === 'undefined' && typeof op2 === 'undefined') {
  503. // end condition: both ops1 and ops2 have been processed
  504. break
  505. }
  506. if (op1 instanceof RemoveOp) {
  507. operation.remove(-op1.length)
  508. op1 = ops1[i1++]
  509. continue
  510. }
  511. if (op2 instanceof InsertOp) {
  512. operation.insert(op2.insertion, {
  513. tracking: op2.tracking,
  514. commentIds: op2.commentIds,
  515. })
  516. op2 = ops2[i2++]
  517. continue
  518. }
  519. if (typeof op1 === 'undefined') {
  520. throw new Error(
  521. 'Cannot compose operations: first operation is too short.'
  522. )
  523. }
  524. if (typeof op2 === 'undefined') {
  525. throw new Error(
  526. 'Cannot compose operations: first operation is too long.'
  527. )
  528. }
  529. if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
  530. // If both have tracking info, use the latter one. Otherwise use the
  531. // tracking info from the former.
  532. const tracking = op2.tracking ?? op1.tracking
  533. if (op1.length > op2.length) {
  534. operation.retain(op2.length, {
  535. tracking,
  536. })
  537. op1 = new RetainOp(op1.length - op2.length, op1.tracking)
  538. op2 = ops2[i2++]
  539. } else if (op1.length === op2.length) {
  540. operation.retain(op1.length, {
  541. tracking,
  542. })
  543. op1 = ops1[i1++]
  544. op2 = ops2[i2++]
  545. } else {
  546. operation.retain(op1.length, {
  547. tracking,
  548. })
  549. op2 = new RetainOp(op2.length - op1.length, op2.tracking)
  550. op1 = ops1[i1++]
  551. }
  552. } else if (op1 instanceof InsertOp && op2 instanceof RemoveOp) {
  553. if (op1.insertion.length > op2.length) {
  554. op1 = new InsertOp(
  555. op1.insertion.slice(op2.length),
  556. op1.tracking,
  557. op1.commentIds
  558. )
  559. op2 = ops2[i2++]
  560. } else if (op1.insertion.length === op2.length) {
  561. op1 = ops1[i1++]
  562. op2 = ops2[i2++]
  563. } else {
  564. op2 = RemoveOp.fromJSON(op1.insertion.length - op2.length)
  565. op1 = ops1[i1++]
  566. }
  567. } else if (op1 instanceof InsertOp && op2 instanceof RetainOp) {
  568. /** @type InsertOptions */
  569. const opts = {
  570. commentIds: op1.commentIds,
  571. }
  572. if (op2.tracking instanceof TrackingProps) {
  573. // Prefer the tracking info on the second operation
  574. opts.tracking = op2.tracking
  575. } else if (!(op2.tracking instanceof ClearTrackingProps)) {
  576. // The second operation does not cancel the first operation's tracking
  577. opts.tracking = op1.tracking
  578. }
  579. if (op1.insertion.length > op2.length) {
  580. operation.insert(op1.insertion.slice(0, op2.length), opts)
  581. op1 = new InsertOp(
  582. op1.insertion.slice(op2.length),
  583. op1.tracking,
  584. op1.commentIds
  585. )
  586. op2 = ops2[i2++]
  587. } else if (op1.insertion.length === op2.length) {
  588. operation.insert(op1.insertion, opts)
  589. op1 = ops1[i1++]
  590. op2 = ops2[i2++]
  591. } else {
  592. operation.insert(op1.insertion, opts)
  593. op2 = new RetainOp(op2.length - op1.insertion.length, op2.tracking)
  594. op1 = ops1[i1++]
  595. }
  596. } else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
  597. if (op1.length > op2.length) {
  598. operation.remove(-op2.length)
  599. op1 = new RetainOp(op1.length - op2.length, op1.tracking)
  600. op2 = ops2[i2++]
  601. } else if (op1.length === op2.length) {
  602. operation.remove(-op2.length)
  603. op1 = ops1[i1++]
  604. op2 = ops2[i2++]
  605. } else {
  606. operation.remove(op1.length)
  607. op2 = RemoveOp.fromJSON(op1.length - op2.length)
  608. op1 = ops1[i1++]
  609. }
  610. } else {
  611. throw new Error(
  612. "This shouldn't happen: op1: " +
  613. JSON.stringify(op1) +
  614. ', op2: ' +
  615. JSON.stringify(op2)
  616. )
  617. }
  618. }
  619. return operation
  620. }
  621. /**
  622. * Transform takes two operations A and B that happened concurrently and
  623. * produces two operations A' and B' (in an array) such that
  624. * `apply(apply(S, A), B') = apply(apply(S, B), A')`. This function is the
  625. * heart of OT.
  626. * @param {TextOperation} operation1
  627. * @param {TextOperation} operation2
  628. * @returns {[TextOperation, TextOperation]}
  629. */
  630. static transform(operation1, operation2) {
  631. if (operation1.baseLength !== operation2.baseLength) {
  632. throw new Error('Both operations have to have the same base length')
  633. }
  634. const operation1prime = new TextOperation()
  635. const operation2prime = new TextOperation()
  636. const ops1 = operation1.ops
  637. const ops2 = operation2.ops
  638. let i1 = 0
  639. let i2 = 0
  640. let op1 = ops1[i1++]
  641. let op2 = ops2[i2++]
  642. for (;;) {
  643. // At every iteration of the loop, the imaginary cursor that both
  644. // operation1 and operation2 have that operates on the input string must
  645. // have the same position in the input string.
  646. if (typeof op1 === 'undefined' && typeof op2 === 'undefined') {
  647. // end condition: both ops1 and ops2 have been processed
  648. break
  649. }
  650. // next two cases: one or both ops are insert ops
  651. // => insert the string in the corresponding prime operation, skip it in
  652. // the other one. If both op1 and op2 are insert ops, prefer op1.
  653. if (op1 instanceof InsertOp) {
  654. operation1prime.insert(op1.insertion, {
  655. tracking: op1.tracking,
  656. commentIds: op1.commentIds,
  657. })
  658. operation2prime.retain(op1.insertion.length)
  659. op1 = ops1[i1++]
  660. continue
  661. }
  662. if (op2 instanceof InsertOp) {
  663. operation1prime.retain(op2.insertion.length)
  664. operation2prime.insert(op2.insertion, {
  665. tracking: op2.tracking,
  666. commentIds: op2.commentIds,
  667. })
  668. op2 = ops2[i2++]
  669. continue
  670. }
  671. if (typeof op1 === 'undefined') {
  672. throw new Error(
  673. 'Cannot compose operations: first operation is too short.'
  674. )
  675. }
  676. if (typeof op2 === 'undefined') {
  677. throw new Error(
  678. 'Cannot compose operations: first operation is too long.'
  679. )
  680. }
  681. let minl
  682. if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
  683. // Simple case: retain/retain
  684. // If both have tracking info, we use the one from op1
  685. /** @type {TrackingProps | ClearTrackingProps | undefined} */
  686. let operation1primeTracking
  687. /** @type {TrackingProps | ClearTrackingProps | undefined} */
  688. let operation2primeTracking
  689. if (op1.tracking) {
  690. operation1primeTracking = op1.tracking
  691. } else {
  692. operation2primeTracking = op2.tracking
  693. }
  694. if (op1.length > op2.length) {
  695. minl = op2.length
  696. op1 = new RetainOp(op1.length - op2.length, op1.tracking)
  697. op2 = ops2[i2++]
  698. } else if (op1.length === op2.length) {
  699. minl = op2.length
  700. op1 = ops1[i1++]
  701. op2 = ops2[i2++]
  702. } else {
  703. minl = op1.length
  704. op2 = new RetainOp(op2.length - op1.length, op2.tracking)
  705. op1 = ops1[i1++]
  706. }
  707. operation1prime.retain(minl, { tracking: operation1primeTracking })
  708. operation2prime.retain(minl, { tracking: operation2primeTracking })
  709. } else if (op1 instanceof RemoveOp && op2 instanceof RemoveOp) {
  710. // Both operations remove the same string at the same position. We don't
  711. // need to produce any operations, we just skip over the remove ops and
  712. // handle the case that one operation removes more than the other.
  713. if (op1.length > op2.length) {
  714. op1 = RemoveOp.fromJSON(op2.length - op1.length)
  715. op2 = ops2[i2++]
  716. } else if (op1.length === op2.length) {
  717. op1 = ops1[i1++]
  718. op2 = ops2[i2++]
  719. } else {
  720. op2 = RemoveOp.fromJSON(op1.length - op2.length)
  721. op1 = ops1[i1++]
  722. }
  723. // next two cases: remove/retain and retain/remove
  724. } else if (op1 instanceof RemoveOp && op2 instanceof RetainOp) {
  725. if (op1.length > op2.length) {
  726. minl = op2.length
  727. op1 = RemoveOp.fromJSON(op2.length - op1.length)
  728. op2 = ops2[i2++]
  729. } else if (op1.length === op2.length) {
  730. minl = op2.length
  731. op1 = ops1[i1++]
  732. op2 = ops2[i2++]
  733. } else {
  734. minl = op1.length
  735. op2 = new RetainOp(op2.length - op1.length, op2.tracking)
  736. op1 = ops1[i1++]
  737. }
  738. operation1prime.remove(minl)
  739. } else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
  740. if (op1.length > op2.length) {
  741. minl = op2.length
  742. op1 = new RetainOp(op1.length - op2.length, op1.tracking)
  743. op2 = ops2[i2++]
  744. } else if (op1.length === op2.length) {
  745. minl = op1.length
  746. op1 = ops1[i1++]
  747. op2 = ops2[i2++]
  748. } else {
  749. minl = op1.length
  750. op2 = RemoveOp.fromJSON(op1.length - op2.length)
  751. op1 = ops1[i1++]
  752. }
  753. operation2prime.remove(minl)
  754. } else {
  755. throw new Error("The two operations aren't compatible")
  756. }
  757. }
  758. return [operation1prime, operation2prime]
  759. }
  760. }
  761. // Operation are essentially lists of ops. There are three types of ops:
  762. //
  763. // * Retain ops: Advance the cursor position by a given number of characters.
  764. // Represented by positive ints.
  765. // * Insert ops: Insert a given string at the current cursor position.
  766. // Represented by strings.
  767. // * Remove ops: Remove the next n characters. Represented by negative ints.
  768. /**
  769. *
  770. * @param {TextOperation} operation
  771. * @returns {ScanOp | null}
  772. */
  773. function getSimpleOp(operation) {
  774. const ops = operation.ops
  775. switch (ops.length) {
  776. case 1:
  777. return ops[0]
  778. case 2:
  779. return ops[0] instanceof RetainOp
  780. ? ops[1]
  781. : ops[1] instanceof RetainOp
  782. ? ops[0]
  783. : null
  784. case 3:
  785. if (ops[0] instanceof RetainOp && ops[2] instanceof RetainOp) {
  786. return ops[1]
  787. }
  788. }
  789. return null
  790. }
  791. /**
  792. * @param {TextOperation} operation
  793. * @return {number}
  794. */
  795. function getStartIndex(operation) {
  796. if (operation.ops[0] instanceof RetainOp) {
  797. return operation.ops[0].length
  798. }
  799. return 0
  800. }
  801. /**
  802. * Constructs the segments defined as each overlapping range of tracked
  803. * changes and comments. Each segment can have it's own tracking props and
  804. * attached comment ids.
  805. *
  806. * The quick brown fox jumps over the lazy dog
  807. * Tracked inserts ---------- -----
  808. * Tracked deletes ------
  809. * Comment 1 -------
  810. * Comment 2 ----
  811. * Comment 3 -----------------
  812. *
  813. * Approx. boundaries: | | | || | | | |
  814. *
  815. * @param {number} cursor
  816. * @param {number} length
  817. * @param {import('../file_data/comment_list')} commentsList
  818. * @param {TrackedChangeList} trackedChangeList
  819. * @returns {{length: number, commentIds?: string[], tracking?: TrackingProps}[]}
  820. */
  821. function calculateTrackingCommentSegments(
  822. cursor,
  823. length,
  824. commentsList,
  825. trackedChangeList
  826. ) {
  827. const breaks = new Set()
  828. const opStart = cursor
  829. const opEnd = cursor + length
  830. /**
  831. * Utility function to limit breaks to the boundary set by the operation range
  832. * @param {number} rangeBoundary
  833. */
  834. function addBreak(rangeBoundary) {
  835. if (rangeBoundary < opStart || rangeBoundary > opEnd) {
  836. return
  837. }
  838. breaks.add(rangeBoundary)
  839. }
  840. // Add comment boundaries
  841. for (const comment of commentsList.comments.values()) {
  842. for (const range of comment.ranges) {
  843. addBreak(range.end)
  844. addBreak(range.start)
  845. }
  846. }
  847. // Add tracked change boundaries
  848. for (const trackedChange of trackedChangeList.asSorted()) {
  849. addBreak(trackedChange.range.start)
  850. addBreak(trackedChange.range.end)
  851. }
  852. // Add operation boundaries
  853. addBreak(opStart)
  854. addBreak(opEnd)
  855. // Sort the boundaries so that we can construct ranges between them
  856. const sortedBreaks = Array.from(breaks).sort((a, b) => a - b)
  857. const separateRanges = []
  858. for (let i = 1; i < sortedBreaks.length; i++) {
  859. const start = sortedBreaks[i - 1]
  860. const end = sortedBreaks[i]
  861. const currentRange = new Range(start, end - start)
  862. // The comment ids that cover the current range is part of this sub-range
  863. const commentIds = commentsList.idsCoveringRange(currentRange)
  864. // The tracking info that covers the current range is part of this sub-range
  865. const tracking = trackedChangeList.propsAtRange(currentRange)
  866. separateRanges.push({
  867. length: currentRange.length,
  868. commentIds: commentIds.length > 0 ? commentIds : undefined,
  869. tracking,
  870. })
  871. }
  872. return separateRanges
  873. }
  874. module.exports = TextOperation