text_operation.js 27 KB

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