diff --git a/package-lock.json b/package-lock.json index 1388ff88..8fdb6e19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,14 +13,14 @@ "y-protocols": "^1.0.5" }, "devDependencies": { - "@types/node": "^18.15.5", + "@types/node": "^22.14.1", "concurrently": "^3.6.1", "jsdoc": "^3.6.7", "markdownlint-cli": "^0.41.0", "rollup": "^4.37.0", "standard": "^16.0.4", "tui-jsdoc-template": "^1.2.2", - "typescript": "^4.9.5", + "typescript": "^5.8.3", "yjs": "." }, "engines": { @@ -557,12 +557,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.15.tgz", - "integrity": "sha512-AMZ2UWx+woHNfM11PyAEQmfSxi05jm9OlkxczuHeEqmvwPkYj6MWv44gbzDPefYOLysTOFyI3ziiy2ONmUZfpA==", + "version": "22.14.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", + "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, "node_modules/acorn": { @@ -4547,16 +4548,17 @@ } }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/uc.micro": { @@ -4587,10 +4589,11 @@ "dev": true }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" }, "node_modules/uri-js": { "version": "4.4.1", diff --git a/package.json b/package.json index 22607b6a..46686830 100644 --- a/package.json +++ b/package.json @@ -90,14 +90,14 @@ "y-protocols": "^1.0.5" }, "devDependencies": { - "@types/node": "^18.15.5", + "@types/node": "^22.14.1", "concurrently": "^3.6.1", "jsdoc": "^3.6.7", "markdownlint-cli": "^0.41.0", "rollup": "^4.37.0", "standard": "^16.0.4", "tui-jsdoc-template": "^1.2.2", - "typescript": "^4.9.5", + "typescript": "^5.8.3", "yjs": "." }, "engines": { diff --git a/src/internals.js b/src/internals.js index f7005afd..6741e0e0 100644 --- a/src/internals.js +++ b/src/internals.js @@ -41,3 +41,4 @@ export * from './structs/ContentType.js' export * from './structs/Item.js' export * from './structs/Skip.js' export * from './utils/IdMap.js' +export * from './utils/AttributionManager.js' diff --git a/src/types/YText.js b/src/types/YText.js index 0a70f371..053f23a2 100644 --- a/src/types/YText.js +++ b/src/types/YText.js @@ -26,8 +26,7 @@ import { updateMarkerChanges, ContentType, warnPrematureAccess, - ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction, IdMap, // eslint-disable-line - snapshot + noAttributionsManager, AbstractAttributionManager, ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction // eslint-disable-line } from '../internals.js' import * as delta from '../utils/Delta.js' @@ -999,85 +998,63 @@ export class YText extends AbstractType { * Note that deleted content that was not deleted in prevYdoc is rendered as an insertion with the * attribution `{ isDeleted: true, .. }`. * - * @param {IdMap} [idMap] - * @param {Doc} [prevYdoc] + * @param {AbstractAttributionManager} am * @return {import('../utils/Delta.js').Delta} The Delta representation of this type. * * @public */ - getContent (idMap, prevYdoc) { + getContent (am = noAttributionsManager) { this.doc ?? warnPrematureAccess() - const prevSnapshot = prevYdoc ? snapshot(prevYdoc) : null const d = delta.create() - /** - * @type {{ [key: string]: any }} - */ - const currentAttributes = {} - const doc = /** @type {Doc} */ (this.doc) - const computeContent = () => { - let n = this._start - while (n !== null) { - 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' }) + for (let item = this._start; item !== null; item = item.right) { + const cs = am.getContent(item) + for (let i = 0; i < cs.length; i++) { + const { content, deleted, attrs } = cs[i] + /** + * @type {{ [key: string]: any }?} + */ + let attributions = null + if (attrs != null) { + attributions = {} + attrs.forEach(attr => { + switch (attr.name) { + case '_insertedBy': + case '_deletedBy': + case '_suggestedBy': { + const as = /** @type {any} */ (attributions) + const ls = as[attr.name] = as[attr.name] ?? [] + ls.push(attr.val) + break } - } 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' }) + default: { + if (attr.name[0] !== '_') { + /** @type {any} */ (attributions)[attr.name] = attr.val + } } - } else if (cur !== undefined) { - packStr() - currentAttributes.delete('ychange') } - str += /** @type {ContentString} */ (n.content).str + }) + } + switch (content.constructor) { + case ContentString: { + d.insert(/** @type {ContentString} */ (content).str, {}, attributions) 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) + d.insert(/** @type {ContentEmbed | ContentType} */ (content).getContent()[0], {}, attributions) break } case ContentFormat: - if (isVisible(n, snapshot)) { - packStr() - updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (n.content)) + if (attributions != null) { + attributions.formattedBy = (deleted ? attributions.deletedBy : attributions.insertedBy) ?? [] + delete attributions.deletedBy + delete attributions.insertedBy + d.useAttribution(attributions) } break } - n = n.right } } - if (prevSnapshot) { - // snapshots are merged again after the transaction, so we need to keep the - // transaction alive until we are done - transact(doc, transaction => { - if (prevSnapshot) { - splitSnapshotAffectedStructs(transaction, prevSnapshot) - } - computeContent() - }, 'cleanup') - } else { - computeContent() - } return d.done() } diff --git a/src/utils/AttributionManager.js b/src/utils/AttributionManager.js new file mode 100644 index 00000000..e3f42a1b --- /dev/null +++ b/src/utils/AttributionManager.js @@ -0,0 +1,88 @@ +import { + Item, AbstractContent, IdMap // eslint-disable-line +} from '../internals.js' + +import * as error from 'lib0/error' + +/** + * @template T + */ +export class AttributedContent { + /** + * @param {AbstractContent} content + * @param {boolean} deleted + * @param {Array> | null} attrs + */ + constructor (content, deleted, attrs) { + this.content = content + this.deleted = deleted + this.attrs = attrs + } +} + +/** + * Abstract class for associating Attributions to content / changes + */ +export class AbstractAttributionManager { + /** + * @param {Item} _item + * @return {Array>} + */ + getContent (_item) { + error.methodUnimplemented() + } +} + +/** + * Abstract class for associating Attributions to content / changes + * + * @implements AbstractAttributionManager + */ +export class TwosetAttributionManager { + /** + * @param {IdMap} inserts + * @param {IdMap} deletes + */ + constructor (inserts, deletes) { + this.inserts = inserts + this.deletes = deletes + } + + /** + * @param {Item} item + * @return {Array>} + */ + getContent (item) { + const deleted = item.deleted + const slice = (deleted ? this.deletes : this.inserts).slice(item.id, item.length) + let content = slice.length === 1 ? item.content : item.content.copy() + let res = slice.map(s => { + const c = content + if (s.len < c.getLength()) { + content = c.splice(s.len) + } + return new AttributedContent(c, deleted, s.attrs) + }) + if (deleted) { + res = res.filter(s => s.attrs != null) + } + return res + } +} + +/** + * Abstract class for associating Attributions to content / changes + * + * @implements AbstractAttributionManager + */ +export class NoAttributionsManager { + /** + * @param {Item} item + * @return {Array>} + */ + getContent (item) { + return item.deleted ? [] : [new AttributedContent(item.content, item.deleted, null)] + } +} + +export const noAttributionsManager = new NoAttributionsManager() diff --git a/src/utils/IdMap.js b/src/utils/IdMap.js index 206c89ab..0e0d3197 100644 --- a/src/utils/IdMap.js +++ b/src/utils/IdMap.js @@ -105,6 +105,11 @@ export class AttrRange { } } +/** + * @template Attrs + * @typedef {{ clock: number, len: number, attrs: Array>? }} MaybeAttrRange + */ + /** * @template Attrs */ @@ -287,12 +292,18 @@ export class IdMap { } /** + * Return attributions for a slice of ids. + * * @param {ID} id * @param {number} len - * @return {Array>?} + * @return {Array>} */ slice (id, len) { const dr = this.clients.get(id.client) + /** + * @type {Array>} + */ + const res = [] if (dr) { /** * @type {Array>} @@ -300,7 +311,7 @@ export class IdMap { const ranges = dr.getIds() let index = findIndexInIdRanges(ranges, id.clock) if (index !== null) { - const res = [] + let prev = null while (index < ranges.length) { let r = ranges[index] if (r.clock < id.clock) { @@ -310,13 +321,26 @@ export class IdMap { r = new AttrRange(r.clock, id.clock + len - r.clock, r.attrs) } if (r.len <= 0) break + const prevEnd = prev != null ? prev.clock + prev.len : index + if (prevEnd < index) { + res.push(/** @type {MaybeAttrRange} */ (new AttrRange(prevEnd, index - prevEnd, /** @type {any} */ (null)))) + } + prev = r res.push(r) index++ } - return res } } - return null + if (res.length > 0) { + const last = res[res.length - 1] + const end = last.clock + last.len + if (end < id.clock + len) { + res.push(new AttrRange(end, id.clock + len - end, [])) + } + } else { + res.push(new AttrRange(id.clock, len, [])) + } + return res } /** diff --git a/tests/IdMap.tests.js b/tests/IdMap.tests.js index 1c21e9d3..68a595ca 100644 --- a/tests/IdMap.tests.js +++ b/tests/IdMap.tests.js @@ -95,7 +95,9 @@ export const testRepeatMergingMultipleIdMaps = tc => { const mergedAttrs = merged.slice(new ID(iclient, iclock), 1) if (mergedAttrs) { mergedAttrs.forEach(a => { - composed.add(iclient, a.clock, a.len, a.attrs) + if (a.attrs != null) { + composed.add(iclient, a.clock, a.len, a.attrs) + } }) } }