text_operation.js 28 KB

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