From 8788a4b9e08a0b240160cee5c0f9de74c95b0c1c Mon Sep 17 00:00:00 2001 From: Bartosz Sypytkowski Date: Sat, 8 Jul 2023 08:54:46 +0200 Subject: [PATCH] quotations: fix splitting quoted item and inserting new item in quoted range --- src/structs/Item.js | 23 +++++-- src/types/YArray.js | 2 +- src/types/YMap.js | 2 +- src/types/YText.js | 29 ++++++++- src/types/YWeakLink.js | 104 +++++++++++++++++++++++++++++-- tests/y-weak-links.tests.js | 121 ++++++++++++++++++++++++++++++++++++ 6 files changed, 270 insertions(+), 11 deletions(-) diff --git a/src/structs/Item.js b/src/structs/Item.js index f9e5d5d5..ed657d3d 100644 --- a/src/structs/Item.js +++ b/src/structs/Item.js @@ -24,7 +24,8 @@ import { addChangedTypeToTransaction, isDeleted, StackItem, DeleteSet, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction, // eslint-disable-line - YWeakLink + YWeakLink, + joinLinkedRange } from '../internals.js' import * as error from 'lib0/error' @@ -106,6 +107,14 @@ export const splitItem = (transaction, leftItem, diff) => { if (leftItem.redone !== null) { rightItem.redone = createID(leftItem.redone.client, leftItem.redone.clock + diff) } + if (leftItem.linked) { + rightItem.linked = true + const allLinks = transaction.doc.store.linkedBy + const linkedBy = allLinks.get(leftItem) + if (linkedBy !== undefined) { + allLinks.set(rightItem, new Set(linkedBy)) + } + } // update left (do not set leftItem.rightOrigin as it will lead to problems when syncing) leftItem.right = rightItem // update right @@ -554,9 +563,15 @@ export class Item extends AbstractStruct { this.left.delete(transaction) } } - // adjust length of parent - if (this.parentSub === null && this.countable && !this.deleted) { - /** @type {AbstractType} */ (this.parent)._length += this.length + if (this.parentSub === null && !this.deleted) { + if (this.countable) { + // adjust length of parent + /** @type {AbstractType} */ (this.parent)._length += this.length + } + if (this.left && this.left.linked && this.right && this.right.linked) { + // this item exists within a quoted range + joinLinkedRange(transaction, this) + } } addStruct(transaction.doc.store, this) this.content.integrate(transaction, this) diff --git a/src/types/YArray.js b/src/types/YArray.js index e2d91614..b765c5d2 100644 --- a/src/types/YArray.js +++ b/src/types/YArray.js @@ -212,7 +212,7 @@ export class YArray extends AbstractType { * @param {number} length The number of elements to include in returned weak link reference. * @return {YWeakLink} */ - quote(index, length = 1) { + quote (index, length = 1) { if (this.doc !== null) { return transact(this.doc, transaction => { return arrayWeakLink(transaction, this, index, length) diff --git a/src/types/YMap.js b/src/types/YMap.js index 845e34ae..dc42b93b 100644 --- a/src/types/YMap.js +++ b/src/types/YMap.js @@ -241,7 +241,7 @@ export class YMap extends AbstractType { * @param {string} key * @return {YWeakLink|undefined} */ - link(key) { + link (key) { return mapWeakLink(this, key) } diff --git a/src/types/YText.js b/src/types/YText.js index 399a6ff3..7c300520 100644 --- a/src/types/YText.js +++ b/src/types/YText.js @@ -27,12 +27,14 @@ import { typeMapGetAll, updateMarkerChanges, ContentType, - ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction // eslint-disable-line + ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction, // eslint-disable-line + quoteText } from '../internals.js' import * as object from 'lib0/object' import * as map from 'lib0/map' import * as error from 'lib0/error' +import { WeakLink } from 'yjs' /** * @param {any} a @@ -1155,6 +1157,31 @@ export class YText extends AbstractType { } } + /** + * Returns a WeakLink representing a dynamic quotation of a range of elements. + * + * In case when quotation happens in a middle of formatting range, formatting + * attributes will be split into before|within|after eg. quoting fragment of + * `hello world` could result in `he"llo wo"rld` + * where `"llo wo"` represents quoted range. + * + * @param {number} index The index where quoted range should start + * @param {number} length Number of quoted elements + * @return {WeakLink} + * + * @public + */ + quote (index, length) { + const y = this.doc + if (y !== null) { + return transact(y, transaction => { + const pos = findPosition(transaction, this, index) + return quoteText(transaction, this, pos, length) + }) + } + throw new Error('cannot quote YText which has not been integrated into any Doc') + } + /** * Deletes text starting from an index. * diff --git a/src/types/YWeakLink.js b/src/types/YWeakLink.js index b86bded3..053b05bc 100644 --- a/src/types/YWeakLink.js +++ b/src/types/YWeakLink.js @@ -11,7 +11,9 @@ import { YWeakLinkRefID, writeID, readID, - RelativePosition + RelativePosition, + ItemTextListPosition, + ContentString } from "../internals.js" /** @@ -53,7 +55,7 @@ export class YWeakLink extends AbstractType { * * @returns {boolean} */ - isSingle() { + isSingle () { return this._quoteStart.item === this._quoteEnd.item } @@ -62,7 +64,7 @@ export class YWeakLink extends AbstractType { * * @return {T|undefined} */ - deref() { + deref () { if (this._firstItem !== null) { let item = this._firstItem if (item.parentSub !== null) { @@ -85,7 +87,7 @@ export class YWeakLink extends AbstractType { * * @return {Array} */ - unqote() { + unqote () { let result = /** @type {Array} */ ([]) let item = this._firstItem const end = /** @type {ID} */ (this._quoteEnd.item) @@ -182,6 +184,31 @@ export class YWeakLink extends AbstractType { writeID(encoder.restEncoder, /** @type {ID} */ (this._quoteEnd.item)) } } + + /** + * Returns the unformatted string representation of this quoted text range. + * + * @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 + } + const lastId = n.lastId + if (lastId.client === end.client && lastId.clock === end.clock) { + break; + } + n = n.right + } + return str + } } @@ -259,6 +286,50 @@ export const arrayWeakLink = (transaction, parent, index, length = 1) => { throw invalidQuotedRange } +/** + * Returns a {WeakLink} to an YMap element at given key. + * + * @param {Transaction} transaction + * @param {AbstractType} parent + * @param {ItemTextListPosition} pos + * @param {number} length + * @return {YWeakLink} + */ +export const quoteText = (transaction, parent, pos, length) => { + if (pos.right !== null) { + const startItem = pos.right + const endIndex = pos.index + length + while (pos.index < endIndex) { + pos.forward() + } + if (pos.left !== null) { + let endItem = pos.left + if (pos.index > endIndex) { + const overflow = pos.index - endIndex + endItem = getItemCleanEnd(transaction, transaction.doc.store, createID(endItem.id.client, endItem.id.clock + endItem.length - overflow - 1)) + } + const start = new RelativePosition(null, null, startItem.id, 0) + const end = new RelativePosition(null, null, endItem.lastId, -1) + const link = new YWeakLink(start, end, startItem) + if (parent.doc !== null) { + transact(parent.doc, (transaction) => { + const end = /** @type {ID} */ (link._quoteEnd.item) + for (let item = link._firstItem; item !== null; item = item = item.right) { + createLink(transaction, item, link) + const lastId = item.lastId + if (lastId.client === end.client && lastId.clock === end.clock) { + break; + } + } + }) + } + return link + } + } + + throw invalidQuotedRange +} + /** * Returns a {WeakLink} to an YMap element at given key. * @@ -319,4 +390,29 @@ export const unlinkFrom = (transaction, source, linkRef) => { } } } +} + +/** + * Rebinds linkedBy links pointed between neighbours of a current item. + * This method expects that current item has both left and right neighbours. + * + * @param {Transaction} transaction + * @param {Item} item + */ +export const joinLinkedRange = (transaction, item) => { + item.linked = true + const allLinks = transaction.doc.store.linkedBy + const leftLinks = allLinks.get(/** @type {Item} */ (item.left)) + const rightLinks = allLinks.get(/** @type {Item} */ (item.right)) + if (leftLinks && rightLinks) { + const common = new Set() + for (let link of leftLinks) { + if (rightLinks.has(link)) { + common.add(link) + } + } + if (common.size != 0) { + allLinks.set(item, common) + } + } } \ No newline at end of file diff --git a/tests/y-weak-links.tests.js b/tests/y-weak-links.tests.js index a70ed108..21c17b24 100644 --- a/tests/y-weak-links.tests.js +++ b/tests/y-weak-links.tests.js @@ -472,6 +472,75 @@ export const testDeepObserveArray = tc => { //FIXME t.compare(events.length, 1) t.compare(events[0].target, link) } + +/** + * @param {t.TestCase} tc + */ +export const testDeepObserveNewElementWithinQuotedRange = tc => { + const { testConnector, users, array0, array1 } = init(tc, { users: 2 }) + const m1 = new Y.Map() + const m3 = new Y.Map() + array0.insert(0, [1,m1,m3,2]) + const link0 = array0.quote(1, 2) + array0.insert(0, [link0]) + + testConnector.flushAllMessages() + + /** + * @type {Array} + */ + let e0 = [] + link0.observeDeep((evts) => { + e0 = [] + for (let e of evts) { + switch (e.constructor) { + case Y.YMapEvent: + e0.push({target: e.target, keys: e.keys}) + break; + case Y.YWeakLinkEvent: + e0.push({target: e.target}) + break; + default: throw new Error('unexpected event type ' + e.constructor) + } + } + }) + + const link1 = /** @type {Y.WeakLink} */ (array1.get(0)) + /** + * @type {Array} + */ + let e1 = [] + link1.observeDeep((evts) => { + e1 = [] + for (let e of evts) { + switch (e.constructor) { + case Y.YMapEvent: + e1.push({target: e.target, keys: e.keys}) + break; + case Y.YWeakLinkEvent: + e1.push({target: e.target}) + break; + default: throw new Error('unexpected event type ' + e.constructor) + } + } + }) + + const m20 = new Y.Map() + array0.insert(3, [m20]) + + m20.set('key', 'value') + t.compare(e0.length, 1) + t.compare(e0[0].target, m20) + t.compare(e0[0].keys, new Map([['key', {action:'add', oldValue: undefined}]])) + + testConnector.flushAllMessages() + + const m21 = array1.get(3) + t.compare(e1.length, 1) + t.compare(e1[0].target, m21) + t.compare(e1[0].keys, new Map([['key', {action:'add', oldValue: undefined}]])) +} + /** * @param {t.TestCase} tc */ @@ -608,4 +677,56 @@ export const testRemoteMapUpdate = tc => { t.compare(link0.deref(), 3) t.compare(link1.deref(), 3) t.compare(link2.deref(), 3) +} + +/** + * @param {t.TestCase} tc + */ +export const testTextBasic = tc => { + const { testConnector, text0, array0, text1 } = init(tc, { users: 2 }) + + text0.insert(0, 'abcd') + const link0 = text0.quote(1, 2) + t.compare(link0.toString(), 'bc') + text0.insert(2, 'ef') + t.compare(link0.toString(), 'befc') + text0.delete(3, 3) + t.compare(link0.toString(), 'be') + text0.insertEmbed(3, link0) + + testConnector.flushAllMessages() + + const delta = text1.toDelta() + const { insert } = delta[1] + t.compare(insert.toString(), 'be') +} + +/** + * @param {t.TestCase} tc + */ +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' + const l2 = text.quote(2, 1) // 'c' + const l3 = text.quote(3, 2) // 'de' + + t.compare(l1.toString(), 'ab') + t.compare(l2.toString(), 'c') + t.compare(l3.toString(), 'de') + + text2.insertEmbed(0, l1) + text2.insertEmbed(1, l2) + text2.insertEmbed(2, l3) + + const delta = text2.toDelta() + t.compare(delta, [ + {insert: l1}, + {insert: l2}, + {insert: l3}, + ]) } \ No newline at end of file