text_operation.js 27 KB

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