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} - */ - 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} - */ - const attributes = {} - let addAttributes = false - currentAttributes.forEach((value, key) => { - addAttributes = true - attributes[key] = value - }) - /** - * @type {Object} - */ - 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} - */ - const op = { - insert: n.content.getContent()[0] - } - if (currentAttributes.size > 0) { - const attrs = /** @type {Object} */ ({}) - 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} 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} + */ + 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} + */ + const attributes = {} + let addAttributes = false + currentAttributes.forEach((value, key) => { + addAttributes = true + attributes[key] = value + }) + /** + * @type {Object} + */ + 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} + */ + const op = { + insert: n.content.getContent()[0] + } + if (currentAttributes.size > 0) { + const attrs = /** @type {Object} */ ({}) + 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} */ - unqote () { + unquote () { let result = /** @type {Array} */ ([]) 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} */ (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} + */ + toDelta(snapshot, prevSnapshot, computeYChange) { + if (this._firstItem !== null && this._quoteStart.item !== null && this._quoteEnd.item !== null) { + const parent = /** @type {AbstractType} */ (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 += `` - } - 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 += `` + } + 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} */ (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}) // 'abcde' - const l1 = text.quote(0, 2) // 'ab' + text.format(0, 1, {b:true}) + text.format(1, 3, {i:true}) // 'abcde' + const l1 = text.quote(0, 2) + t.compare(l1.toString(), 'ab') const l2 = text.quote(2, 1) // 'c' - const l3 = text.quote(3, 2) // 'de' - - t.compare(l1.toString(), 'ab') t.compare(l2.toString(), 'c') + const l3 = text.quote(3, 2) // 'de' t.compare(l3.toString(), 'de') text2.insertEmbed(0, l1)