From 160c9ca1b752c8c7f427be7df7ee85f9c71e10d3 Mon Sep 17 00:00:00 2001
From: Bartosz Sypytkowski <b.sypytkowski@gmail.com>
Date: Fri, 14 Jul 2023 10:00:57 +0200
Subject: [PATCH] quotations ove text with XML formatted strings

---
 src/types/YText.js          | 239 +++++++++++++++++++++---------------
 src/types/YWeakLink.js      |  66 +++++++---
 src/types/YXmlText.js       |  68 +++++-----
 tests/y-weak-links.tests.js |  70 +++++------
 4 files changed, 259 insertions(+), 184 deletions(-)

diff --git a/src/types/YText.js b/src/types/YText.js
index 7c300520..40c861cc 100644
--- a/src/types/YText.js
+++ b/src/types/YText.js
@@ -1002,107 +1002,7 @@ export class YText extends AbstractType {
    * @public
    */
   toDelta (snapshot, prevSnapshot, computeYChange) {
-    /**
-     * @type{Array<any>}
-     */
-    const ops = []
-    const currentAttributes = new Map()
-    const doc = /** @type {Doc} */ (this.doc)
-    let str = ''
-    let n = this._start
-    function packStr () {
-      if (str.length > 0) {
-        // pack str with attributes to ops
-        /**
-         * @type {Object<string,any>}
-         */
-        const attributes = {}
-        let addAttributes = false
-        currentAttributes.forEach((value, key) => {
-          addAttributes = true
-          attributes[key] = value
-        })
-        /**
-         * @type {Object<string,any>}
-         */
-        const op = { insert: str }
-        if (addAttributes) {
-          op.attributes = attributes
-        }
-        ops.push(op)
-        str = ''
-      }
-    }
-    const computeDelta = () => {
-      while (n !== null) {
-        if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) {
-          switch (n.content.constructor) {
-            case ContentString: {
-              const cur = currentAttributes.get('ychange')
-              if (snapshot !== undefined && !isVisible(n, snapshot)) {
-                if (cur === undefined || cur.user !== n.id.client || cur.type !== 'removed') {
-                  packStr()
-                  currentAttributes.set('ychange', computeYChange ? computeYChange('removed', n.id) : { type: 'removed' })
-                }
-              } else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) {
-                if (cur === undefined || cur.user !== n.id.client || cur.type !== 'added') {
-                  packStr()
-                  currentAttributes.set('ychange', computeYChange ? computeYChange('added', n.id) : { type: 'added' })
-                }
-              } else if (cur !== undefined) {
-                packStr()
-                currentAttributes.delete('ychange')
-              }
-              str += /** @type {ContentString} */ (n.content).str
-              break
-            }
-            case ContentType:
-            case ContentEmbed: {
-              packStr()
-              /**
-               * @type {Object<string,any>}
-               */
-              const op = {
-                insert: n.content.getContent()[0]
-              }
-              if (currentAttributes.size > 0) {
-                const attrs = /** @type {Object<string,any>} */ ({})
-                op.attributes = attrs
-                currentAttributes.forEach((value, key) => {
-                  attrs[key] = value
-                })
-              }
-              ops.push(op)
-              break
-            }
-            case ContentFormat:
-              if (isVisible(n, snapshot)) {
-                packStr()
-                updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (n.content))
-              }
-              break
-          }
-        }
-        n = n.right
-      }
-      packStr()
-    }
-    if (snapshot || prevSnapshot) {
-      // snapshots are merged again after the transaction, so we need to keep the
-      // transaction alive until we are done
-      transact(doc, transaction => {
-        if (snapshot) {
-          splitSnapshotAffectedStructs(transaction, snapshot)
-        }
-        if (prevSnapshot) {
-          splitSnapshotAffectedStructs(transaction, prevSnapshot)
-        }
-        computeDelta()
-      }, 'cleanup')
-    } else {
-      computeDelta()
-    }
-    return ops
+    return rangeDelta(this, null, null, snapshot, prevSnapshot, computeYChange)
   }
 
   /**
@@ -1179,7 +1079,7 @@ export class YText extends AbstractType {
         return quoteText(transaction, this, pos, length)
       })
     }
-    throw new Error('cannot quote YText which has not been integrated into any Doc')    
+    throw new Error('Quoted text was not integrated into Doc')    
   }
 
   /**
@@ -1315,3 +1215,138 @@ export class YText extends AbstractType {
  * @function
  */
 export const readYText = _decoder => new YText()
+
+/**
+ * Returns a delta representation that happens between `start` and `end` ranges (both sides inclusive).
+ * 
+ * @param {AbstractType<any>} parent 
+ * @param {ID|null} start
+ * @param {ID|null} end
+ * @param {Snapshot|undefined} snapshot 
+ * @param {Snapshot|undefined} prevSnapshot 
+ * @param {(function('removed' | 'added', ID):any)|undefined} computeYChange 
+ * @returns {any} The Delta representation of this type.
+ */
+export const rangeDelta = (parent, start, end, snapshot, prevSnapshot, computeYChange) => {
+    /**
+     * @type{Array<any>}
+     */
+    const ops = []
+    const currentAttributes = new Map()
+    const doc = /** @type {Doc} */ (parent.doc)
+    let str = ''
+    let n = parent._start
+    function packStr () {
+      if (str.length > 0) {
+        // pack str with attributes to ops
+        /**
+         * @type {Object<string,any>}
+         */
+        const attributes = {}
+        let addAttributes = false
+        currentAttributes.forEach((value, key) => {
+          addAttributes = true
+          attributes[key] = value
+        })
+        /**
+         * @type {Object<string,any>}
+         */
+        const op = { insert: str }
+        if (addAttributes) {
+          op.attributes = attributes
+        }
+        ops.push(op)
+        str = ''
+      }
+    }
+    const computeDelta = () => {
+      // scope represents offset at current block from which we're intersted in picking string
+      // if it's -1 it means, we're out of scope and we should break at this point
+      let scope = start === null ? 0 : -1 
+      loop: while (n !== null) {
+        if (scope < 0 && start !== null) {
+          if (start.client === n.id.client && start.clock >= n.id.clock && start.clock < n.id.clock + n.length) {
+            scope = n.id.clock + n.length - start.clock - 1
+          }
+        }
+        if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) {
+          switch (n.content.constructor) {
+            case ContentString: {
+              const cur = currentAttributes.get('ychange')
+              if (snapshot !== undefined && !isVisible(n, snapshot)) {
+                if (cur === undefined || cur.user !== n.id.client || cur.type !== 'removed') {
+                  packStr()
+                  currentAttributes.set('ychange', computeYChange ? computeYChange('removed', n.id) : { type: 'removed' })
+                }
+              } else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) {
+                if (cur === undefined || cur.user !== n.id.client || cur.type !== 'added') {
+                  packStr()
+                  currentAttributes.set('ychange', computeYChange ? computeYChange('added', n.id) : { type: 'added' })
+                }
+              } else if (cur !== undefined) {
+                packStr()
+                currentAttributes.delete('ychange')
+              }
+              let s = /** @type {ContentString} */ (n.content).str
+              if (scope > 0) {
+                 str += s.slice(scope)
+                 scope = 0
+              } else if (end !== null && end.client === n.id.client && end.clock >= n.id.clock && end.clock < n.id.clock + n.length) {
+                // we reached the end or range
+                const offset = n.id.clock + n.length - end.clock - 1
+                str += s.slice(0, s.length + offset) // scope is negative
+                packStr()
+                break loop
+              } else if (scope == 0) {
+                str += s
+              }
+              break
+            }
+            case ContentType:
+            case ContentEmbed: {
+              packStr()
+              /**
+               * @type {Object<string,any>}
+               */
+              const op = {
+                insert: n.content.getContent()[0]
+              }
+              if (currentAttributes.size > 0) {
+                const attrs = /** @type {Object<string,any>} */ ({})
+                op.attributes = attrs
+                currentAttributes.forEach((value, key) => {
+                  attrs[key] = value
+                })
+              }
+              ops.push(op)
+              break
+            }
+            case ContentFormat:
+              if (isVisible(n, snapshot)) {
+                packStr()
+                updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (n.content))
+              }
+              break
+          }
+        }
+        n = n.right
+      }
+      packStr()
+    }
+    if (snapshot || prevSnapshot) {
+      // snapshots are merged again after the transaction, so we need to keep the
+      // transaction alive until we are done
+      transact(doc, transaction => {
+        if (snapshot) {
+          splitSnapshotAffectedStructs(transaction, snapshot)
+        }
+        if (prevSnapshot) {
+          splitSnapshotAffectedStructs(transaction, prevSnapshot)
+        }
+        computeDelta()
+      }, 'cleanup')
+    } else {
+      computeDelta()
+    }
+    return ops
+}
\ No newline at end of file
diff --git a/src/types/YWeakLink.js b/src/types/YWeakLink.js
index 053b05bc..176a471e 100644
--- a/src/types/YWeakLink.js
+++ b/src/types/YWeakLink.js
@@ -13,7 +13,12 @@ import {
   readID,
   RelativePosition,
   ItemTextListPosition,
-  ContentString
+  ContentString,
+  rangeDelta,
+  formatXmlString,
+  Snapshot,
+  YText,
+  YXmlText
 } from "../internals.js"
 
 /**
@@ -87,7 +92,7 @@ export class YWeakLink extends AbstractType {
    * 
    * @return {Array<any>}
    */
-  unqote () {
+  unquote () {
     let result = /** @type {Array<any>} */ ([])
     let item = this._firstItem
     const end = /** @type {ID} */ (this._quoteEnd.item)
@@ -191,23 +196,50 @@ export class YWeakLink extends AbstractType {
    * @public
    */
   toString () {
-    let str = ''
-    /**
-     * @type {Item|null}
-     */
-    let n = this._firstItem
-    const end = /** @type {ID} */ (this._quoteEnd.item)
-    while (n !== null) {
-      if (!n.deleted && n.countable && n.content.constructor === ContentString) {
-        str += /** @type {ContentString} */ (n.content).str
+    if (this._firstItem !== null) {
+      switch (/** @type {AbstractType<any>} */ (this._firstItem.parent).constructor) {
+        case YText: 
+          let str = ''
+          /**
+           * @type {Item|null}
+           */
+          let n = this._firstItem
+          const end = /** @type {ID} */ (this._quoteEnd.item)
+          while (n !== null) {
+            if (!n.deleted && n.countable && n.content.constructor === ContentString) {
+              str += /** @type {ContentString} */ (n.content).str
+            }
+            const lastId = n.lastId
+            if (lastId.client === end.client && lastId.clock === end.clock) {
+              break;
+            }
+            n = n.right
+          }
+          return str
+
+        case YXmlText:
+          return this.toDelta().map(delta => formatXmlString(delta)).join('')
       }
-      const lastId = n.lastId
-      if (lastId.client === end.client && lastId.clock === end.clock) {
-        break;
-      }
-      n = n.right
+    } else {
+      return ''
+    }
+  }
+
+  /**
+   * Returns the Delta representation of quoted part of underlying text type.
+   * 
+   * @param {Snapshot|undefined} [snapshot]
+   * @param {Snapshot|undefined} [prevSnapshot]
+   * @param {function('removed' | 'added', ID):any} [computeYChange]
+   * @returns {Array<any>}
+   */
+  toDelta(snapshot, prevSnapshot, computeYChange) {
+    if (this._firstItem !== null && this._quoteStart.item !== null && this._quoteEnd.item !== null) {
+      const parent = /** @type {AbstractType<any>} */ (this._firstItem.parent)
+      return rangeDelta(parent, this._quoteStart.item, this._quoteEnd.item, snapshot, prevSnapshot, computeYChange)
+    } else {
+      return []
     }
-    return str
   }
 }
 
diff --git a/src/types/YXmlText.js b/src/types/YXmlText.js
index 470ce70f..763b5527 100644
--- a/src/types/YXmlText.js
+++ b/src/types/YXmlText.js
@@ -64,36 +64,7 @@ export class YXmlText extends YText {
 
   toString () {
     // @ts-ignore
-    return this.toDelta().map(delta => {
-      const nestedNodes = []
-      for (const nodeName in delta.attributes) {
-        const attrs = []
-        for (const key in delta.attributes[nodeName]) {
-          attrs.push({ key, value: delta.attributes[nodeName][key] })
-        }
-        // sort attributes to get a unique order
-        attrs.sort((a, b) => a.key < b.key ? -1 : 1)
-        nestedNodes.push({ nodeName, attrs })
-      }
-      // sort node order to get a unique order
-      nestedNodes.sort((a, b) => a.nodeName < b.nodeName ? -1 : 1)
-      // now convert to dom string
-      let str = ''
-      for (let i = 0; i < nestedNodes.length; i++) {
-        const node = nestedNodes[i]
-        str += `<${node.nodeName}`
-        for (let j = 0; j < node.attrs.length; j++) {
-          const attr = node.attrs[j]
-          str += ` ${attr.key}="${attr.value}"`
-        }
-        str += '>'
-      }
-      str += delta.insert
-      for (let i = nestedNodes.length - 1; i >= 0; i--) {
-        str += `</${nestedNodes[i].nodeName}>`
-      }
-      return str
-    }).join('')
+    return this.toDelta().map(delta => formatXmlString(delta)).join('')
   }
 
   /**
@@ -111,6 +82,43 @@ export class YXmlText extends YText {
   }
 }
 
+/**
+ * Formats individual delta segment provided by `Text.toDelta` into XML-formatted string.
+ * 
+ * @param {any} delta 
+ * @returns {string} 
+ */
+export const formatXmlString = (delta) => {
+  const nestedNodes = []
+  for (const nodeName in delta.attributes) {
+    const attrs = []
+    for (const key in delta.attributes[nodeName]) {
+      attrs.push({ key, value: delta.attributes[nodeName][key] })
+    }
+    // sort attributes to get a unique order
+    attrs.sort((a, b) => a.key < b.key ? -1 : 1)
+    nestedNodes.push({ nodeName, attrs })
+  }
+  // sort node order to get a unique order
+  nestedNodes.sort((a, b) => a.nodeName < b.nodeName ? -1 : 1)
+  // now convert to dom string
+  let str = ''
+  for (let i = 0; i < nestedNodes.length; i++) {
+    const node = nestedNodes[i]
+    str += `<${node.nodeName}`
+    for (let j = 0; j < node.attrs.length; j++) {
+      const attr = node.attrs[j]
+      str += ` ${attr.key}="${attr.value}"`
+    }
+    str += '>'
+  }
+  str += delta.insert
+  for (let i = nestedNodes.length - 1; i >= 0; i--) {
+    str += `</${nestedNodes[i].nodeName}>`
+  }
+  return str
+}
+
 /**
  * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
  * @return {YXmlText}
diff --git a/tests/y-weak-links.tests.js b/tests/y-weak-links.tests.js
index 21c17b24..b86369d4 100644
--- a/tests/y-weak-links.tests.js
+++ b/tests/y-weak-links.tests.js
@@ -52,7 +52,7 @@ export const testArrayQuoteMultipleElements = tc => {
   array0.insert(0, [array0.quote(1, 3)])
 
   const link0 = array0.get(0)
-  t.compare(link0.unqote(), [2, nested, 3])
+  t.compare(link0.unquote(), [2, nested, 3])
   t.compare(array0.get(1), 1)
   t.compare(array0.get(2), 2)
   t.compare(array0.get(3), nested)
@@ -61,26 +61,26 @@ export const testArrayQuoteMultipleElements = tc => {
   testConnector.flushAllMessages()
 
   const link1 = array1.get(0)
-  let unqoted = link1.unqote()
-  t.compare(unqoted[0], 2)
-  t.compare(unqoted[1].toJSON(), {'key':'value'})
-  t.compare(unqoted[2], 3)
+  let unquoted = link1.unquote()
+  t.compare(unquoted[0], 2)
+  t.compare(unquoted[1].toJSON(), {'key':'value'})
+  t.compare(unquoted[2], 3)
   t.compare(array1.get(1), 1)
   t.compare(array1.get(2), 2)
   t.compare(array1.get(3).toJSON(), {'key':'value'})
   t.compare(array1.get(4), 3)
 
   array1.insert(3, ['A', 'B'])
-  unqoted = link1.unqote()
-  t.compare(unqoted[0], 2)
-  t.compare(unqoted[1], 'A')
-  t.compare(unqoted[2], 'B')
-  t.compare(unqoted[3].toJSON(), {'key':'value'})
-  t.compare(unqoted[4], 3)
+  unquoted = link1.unquote()
+  t.compare(unquoted[0], 2)
+  t.compare(unquoted[1], 'A')
+  t.compare(unquoted[2], 'B')
+  t.compare(unquoted[3].toJSON(), {'key':'value'})
+  t.compare(unquoted[4], 3)
   
   testConnector.flushAllMessages()
   
-  t.compare(array0.get(0).unqote(), [2, 'A', 'B', nested, 3])
+  t.compare(array0.get(0).unquote(), [2, 'A', 'B', nested, 3])
 }
 
 /**
@@ -92,7 +92,7 @@ export const testSelfQuotation = tc => {
   const link0 = array0.quote(0, 3)
   array0.insert(1, [link0]) // link is inserted into its own range
 
-  t.compare(link0.unqote(), [1, link0, 2, 3])
+  t.compare(link0.unquote(), [1, link0, 2, 3])
   t.compare(array0.get(0), 1)
   t.compare(array0.get(1), link0)
   t.compare(array0.get(2), 2)
@@ -102,8 +102,8 @@ export const testSelfQuotation = tc => {
   testConnector.flushAllMessages()
 
   const link1 = array1.get(1)
-  let unqoted = link1.unqote()
-  t.compare(unqoted, [1, link1, 2, 3])
+  let unquoted = link1.unquote()
+  t.compare(unquoted, [1, link1, 2, 3])
   t.compare(array1.get(0), 1)
   t.compare(array1.get(1), link1)
   t.compare(array1.get(2), 2)
@@ -262,7 +262,7 @@ export const testObserveArray = tc => {
   testConnector.flushAllMessages()
 
   let link1 = /** @type {Y.WeakLink<String>} */ (array1.get(0))
-  t.compare(link1.unqote(), ['B','C'])
+  t.compare(link1.unquote(), ['B','C'])
   /**
    * @type {any}
    */
@@ -270,16 +270,16 @@ export const testObserveArray = tc => {
   link1.observe((e) => target1 = e.target)
 
   array0.delete(2)
-  t.compare(target0.unqote(), ['C'])
+  t.compare(target0.unquote(), ['C'])
 
   testConnector.flushAllMessages()
-  t.compare(target1.unqote(), ['C'])
+  t.compare(target1.unquote(), ['C'])
   
   array1.delete(2)
-  t.compare(target1.unqote(), [])
+  t.compare(target1.unquote(), [])
 
   testConnector.flushAllMessages()
-  t.compare(target0.unqote(), [])
+  t.compare(target0.unquote(), [])
 
   target0 = null
   array0.delete(1)
@@ -682,41 +682,41 @@ export const testRemoteMapUpdate = tc => {
 /**
  * @param {t.TestCase} tc
  */
-export const testTextBasic = tc => {
-  const { testConnector, text0, array0, text1 } = init(tc, { users: 2 })
+const testTextBasic = tc => {
+  const { testConnector, text0, text1 } = init(tc, { users: 2 })
 
-  text0.insert(0, 'abcd')
-  const link0 = text0.quote(1, 2)
+  text0.insert(0, 'abcd')             // 'abcd'
+  const link0 = text0.quote(1, 2)     // quote: [bc]
   t.compare(link0.toString(), 'bc')
-  text0.insert(2, 'ef')
-  t.compare(link0.toString(), 'befc')
-  text0.delete(3, 3)
+  text0.insert(2, 'ef')               // 'abefcd', quote: [befc]
+  t.compare(link0.toString(), 'befc') 
+  text0.delete(3, 3)                  // 'abe', quote: [be]
   t.compare(link0.toString(), 'be')
-  text0.insertEmbed(3, link0)
+  text0.insertEmbed(3, link0)         // 'abe[be]'
   
   testConnector.flushAllMessages()
 
   const delta = text1.toDelta()
-  const { insert } = delta[1]
+  const { insert } = delta[1] // YWeakLink
   t.compare(insert.toString(), 'be')
 }
 
 /**
  * @param {t.TestCase} tc
  */
-const testQuoteFormattedText = tc => {
+export const testQuoteFormattedText = tc => {
   const doc = new Y.Doc()
   const text = /** @type {Y.XmlText} */ (doc.get('text', Y.XmlText))
   const text2 = /** @type {Y.XmlText} */ (doc.get('text2', Y.XmlText))
 
   text.insert(0, 'abcde')
-  text.format(1, 3, {i:true}) // 'a<i>bcd</i>e'
-  const l1 = text.quote(0, 2) // 'a<i>b</i>'
+  text.format(0, 1, {b:true})
+  text.format(1, 3, {i:true}) // '<b>a</b><i>bcd</i>e'
+  const l1 = text.quote(0, 2)
+  t.compare(l1.toString(), '<b>a</b><i>b</i>')
   const l2 = text.quote(2, 1) // '<i>c</i>'
-  const l3 = text.quote(3, 2) // '<i>d</i>e'
-
-  t.compare(l1.toString(), 'a<i>b</i>')
   t.compare(l2.toString(), '<i>c</i>')
+  const l3 = text.quote(3, 2) // '<i>d</i>e'
   t.compare(l3.toString(), '<i>d</i>e')
 
   text2.insertEmbed(0, l1)