text_operation.js 27 KB

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