be able to insert into attributed content

This commit is contained in:
Kevin Jahns
2025-05-21 22:11:28 +02:00
parent 3fd60a2017
commit 5b29e54a59
3 changed files with 188 additions and 272 deletions

View File

@@ -27,8 +27,7 @@ import {
noAttributionsManager, AbstractAttributionManager, ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Item, Transaction, // eslint-disable-line
createAttributionFromAttributionItems,
mergeIdSets,
diffIdSet,
intersectSets
diffIdSet
} from '../internals.js'
import * as delta from '../utils/Delta.js'
@@ -51,12 +50,14 @@ export class ItemTextListPosition {
* @param {Item|null} right
* @param {number} index
* @param {Map<string,any>} currentAttributes
* @param {AbstractAttributionManager} am
*/
constructor (left, right, index, currentAttributes) {
constructor (left, right, index, currentAttributes, am) {
this.left = left
this.right = right
this.index = index
this.currentAttributes = currentAttributes
this.am = am
}
/**
@@ -73,14 +74,86 @@ export class ItemTextListPosition {
}
break
default:
if (!this.right.deleted) {
this.index += this.right.length
}
this.index += this.am.contentLength(this.right)
break
}
this.left = this.right
this.right = this.right.right
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {number} length
* @param {Object<string,any>} attributes
*
* @function
*/
formatText (transaction, parent, length, attributes) {
const doc = transaction.doc
const ownClientId = doc.clientID
minimizeAttributeChanges(this, attributes)
const negatedAttributes = insertAttributes(transaction, parent, this, attributes)
// iterate until first non-format or null is found
// delete all formats with attributes[format.key] != null
// also check the attributes after the first non-format as we do not want to insert redundant negated attributes there
// eslint-disable-next-line no-labels
iterationLoop: while (
this.right !== null &&
(length > 0 ||
(
negatedAttributes.size > 0 &&
(this.right.deleted || this.right.content.constructor === ContentFormat)
)
)
) {
switch (this.right.content.constructor) {
case ContentFormat: {
if (!this.right.deleted) {
const { key, value } = /** @type {ContentFormat} */ (this.right.content)
const attr = attributes[key]
if (attr !== undefined) {
if (equalAttrs(attr, value)) {
negatedAttributes.delete(key)
} else {
if (length === 0) {
// no need to further extend negatedAttributes
// eslint-disable-next-line no-labels
break iterationLoop
}
negatedAttributes.set(key, value)
}
this.right.delete(transaction)
} else {
this.currentAttributes.set(key, value)
}
}
break
}
default:
const rightLen = this.am.contentLength(this.right)
if (length < rightLen) {
getItemCleanStart(transaction, createID(this.right.id.client, this.right.id.clock + length))
}
length -= rightLen
break
}
this.forward()
}
// Quill just assumes that the editor starts with a newline and that it always
// ends with a newline. We only insert that newline when a new newline is
// inserted - i.e when length is bigger than type.length
if (length > 0) {
let newlines = ''
for (; length > 0; length--) {
newlines += '\n'
}
this.right = new Item(createID(ownClientId, getState(doc.store, ownClientId)), this.left, this.left && this.left.lastId, this.right, this.right && this.right.id, parent, null, new ContentString(newlines))
this.right.integrate(transaction, 0)
this.forward()
}
insertNegatedAttributes(transaction, parent, this, negatedAttributes)
}
}
/**
@@ -132,10 +205,10 @@ const findPosition = (transaction, parent, index, useSearchMarker) => {
const currentAttributes = new Map()
const marker = useSearchMarker ? findMarker(parent, index) : null
if (marker) {
const pos = new ItemTextListPosition(marker.p.left, marker.p, marker.index, currentAttributes)
const pos = new ItemTextListPosition(marker.p.left, marker.p, marker.index, currentAttributes, noAttributionsManager)
return findNextPosition(transaction, pos, index - marker.index)
} else {
const pos = new ItemTextListPosition(null, parent._start, 0, currentAttributes)
const pos = new ItemTextListPosition(null, parent._start, 0, currentAttributes, noAttributionsManager)
return findNextPosition(transaction, pos, index)
}
}
@@ -279,81 +352,6 @@ const insertText = (transaction, parent, currPos, text, attributes) => {
insertNegatedAttributes(transaction, parent, currPos, negatedAttributes)
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {ItemTextListPosition} currPos
* @param {number} length
* @param {Object<string,any>} attributes
*
* @private
* @function
*/
const formatText = (transaction, parent, currPos, length, attributes) => {
const doc = transaction.doc
const ownClientId = doc.clientID
minimizeAttributeChanges(currPos, attributes)
const negatedAttributes = insertAttributes(transaction, parent, currPos, attributes)
// iterate until first non-format or null is found
// delete all formats with attributes[format.key] != null
// also check the attributes after the first non-format as we do not want to insert redundant negated attributes there
// eslint-disable-next-line no-labels
iterationLoop: while (
currPos.right !== null &&
(length > 0 ||
(
negatedAttributes.size > 0 &&
(currPos.right.deleted || currPos.right.content.constructor === ContentFormat)
)
)
) {
if (!currPos.right.deleted) {
switch (currPos.right.content.constructor) {
case ContentFormat: {
const { key, value } = /** @type {ContentFormat} */ (currPos.right.content)
const attr = attributes[key]
if (attr !== undefined) {
if (equalAttrs(attr, value)) {
negatedAttributes.delete(key)
} else {
if (length === 0) {
// no need to further extend negatedAttributes
// eslint-disable-next-line no-labels
break iterationLoop
}
negatedAttributes.set(key, value)
}
currPos.right.delete(transaction)
} else {
currPos.currentAttributes.set(key, value)
}
break
}
default:
if (length < currPos.right.length) {
getItemCleanStart(transaction, createID(currPos.right.id.client, currPos.right.id.clock + length))
}
length -= currPos.right.length
break
}
}
currPos.forward()
}
// Quill just assumes that the editor starts with a newline and that it always
// ends with a newline. We only insert that newline when a new newline is
// inserted - i.e when length is bigger than type.length
if (length > 0) {
let newlines = ''
for (; length > 0; length--) {
newlines += '\n'
}
currPos.right = new Item(createID(ownClientId, getState(doc.store, ownClientId)), currPos.left, currPos.left && currPos.left.lastId, currPos.right, currPos.right && currPos.right.id, parent, null, new ContentString(newlines))
currPos.right.integrate(transaction, 0)
currPos.forward()
}
insertNegatedAttributes(transaction, parent, currPos, negatedAttributes)
}
/**
* Call this function after string content has been deleted in order to
* clean up formatting Items.
@@ -651,176 +649,7 @@ export class YTextEvent extends YEvent {
*/
getDelta (am = noAttributionsManager) {
const whatToWatch = mergeIdSets([diffIdSet(this.transaction.insertSet, this.transaction.deleteSet), diffIdSet(this.transaction.deleteSet, this.transaction.insertSet)])
const genericDelta = this.target.getDelta(am, whatToWatch)
return genericDelta;
/*
if (!d.equals(genericDelta)) {
console.log(d.toJSON())
console.log(genericDelta.toJSON())
debugger
const d2 = this.target.getDelta(am, whatToWatch)
throw new Error('should match', d2)
}
return d
const ydoc = /** @type {Doc} */ (this.target.doc)
/**
* @type {import('../utils/Delta.js').TextDelta<TextEmbeds>}
*/
const d = delta.createTextDelta()
transact(ydoc, transaction => {
/**
* @type {import('../utils/Delta.js').FormattingAttributes}
*/
let currentAttributes = {} // saves all current attributes for insert
let usingCurrentAttributes = false
/**
* @type {import('../utils/Delta.js').FormattingAttributes}
*/
let changedAttributes = {} // saves changed attributes for retain
let usingChangedAttributes = false
/**
* @type {import('../utils/Delta.js').FormattingAttributes}
*/
const previousAttributes = {} // The value before changes
const tr = this.transaction
/**
* @type {Array<import('../internals.js').AttributedContent<any>>}
*/
const cs = []
for (let item = this.target._start; item !== null; cs.length = 0, item = item.right) {
const freshDelete = item.deleted && tr.deleteSet.hasId(item.id) && !tr.insertSet.hasId(item.id)
const freshInsert = !item.deleted && tr.insertSet.hasId(item.id)
am.readContent(cs, item.id.client, item.id.clock, item.deleted, item.content, !item.deleted || freshDelete) // do item.right after calling this
for (let i = 0; i < cs.length; i++) {
const c = cs[i]
const { attribution } = createAttributionFromAttributionItems(c.attrs, c.deleted)
switch (c.content.constructor) {
case ContentType:
case ContentEmbed:
if (freshInsert) {
d.usedAttributes = currentAttributes
usingCurrentAttributes = true
d.insert(c.content.getContent()[0], null, attribution)
} else if (freshDelete) {
d.delete(1)
} else if (!c.deleted) {
d.usedAttributes = changedAttributes
usingChangedAttributes = true
d.retain(1)
}
break
case ContentString:
if (freshInsert) {
d.usedAttributes = currentAttributes
usingCurrentAttributes = true
d.insert(/** @type {ContentString} */ (c.content).str, null, attribution)
} else if (freshDelete) {
d.delete(c.content.getLength())
} else if (!c.deleted) {
d.usedAttributes = changedAttributes
usingChangedAttributes = true
d.retain(c.content.getLength())
}
break
case ContentFormat: {
const { key, value } = /** @type {ContentFormat} */ (c.content)
// # update attributes
const currAttrVal = currentAttributes[key] ?? null
if (freshDelete || freshInsert) {
// create fresh references
if (usingCurrentAttributes) {
currentAttributes = object.assign({}, currentAttributes)
usingCurrentAttributes = false
}
if (usingChangedAttributes) {
usingChangedAttributes = false
changedAttributes = object.assign({}, changedAttributes)
}
}
if (freshInsert) {
if (equalAttrs(value, currAttrVal)) {
item.delete(transaction)
} else if (equalAttrs(value, previousAttributes[key] ?? null)) {
delete changedAttributes[key]
} else {
changedAttributes[key] = value
}
if (value == null) {
delete currentAttributes[key]
} else {
currentAttributes[key] = value
}
} else if (freshDelete) {
if (equalAttrs(value, currAttrVal)) {
// nop
} else if (equalAttrs(currAttrVal, previousAttributes[key] ?? null)) {
delete changedAttributes[key]
} else {
changedAttributes[key] = currAttrVal
}
// current attributes doesn't change
previousAttributes[key] = value
} else if (!c.deleted) {
// fresh reference to currentAttributes only
if (usingCurrentAttributes) {
currentAttributes = object.assign({}, currentAttributes)
usingCurrentAttributes = false
}
if (usingChangedAttributes && changedAttributes[key] !== undefined) {
usingChangedAttributes = false
changedAttributes = object.assign({}, changedAttributes)
}
if (value == null) {
delete currentAttributes[key]
} else {
currentAttributes[key] = value
}
delete changedAttributes[key]
previousAttributes[key] = value
}
// # Update Attributions
if (attribution != null) {
/**
* @type {import('../utils/Delta.js').Attribution}
*/
const formattingAttribution = object.assign({}, d.usedAttribution)
const attributesChanged = /** @type {{ [key: string]: Array<any> }} */ (formattingAttribution.attributes = object.assign({}, formattingAttribution.attributes ?? {}))
if (value === null) {
delete attributesChanged[key]
} else {
const by = attributesChanged[key] = (attributesChanged[key]?.slice() ?? [])
by.push(...((c.deleted ? attribution.delete : attribution.insert) ?? []))
const attributedAt = (c.deleted ? attribution.deletedAt : attribution.insertedAt)
if (attributedAt) formattingAttribution.attributedAt = attributedAt
}
if (object.isEmpty(attributesChanged)) {
d.useAttribution(null)
} else {
const attributedAt = (c.deleted ? attribution.deletedAt : attribution.insertedAt)
if (attributedAt != null) formattingAttribution.attributedAt = attributedAt
d.useAttribution(formattingAttribution)
}
}
break
}
}
}
}
})
return d.done()
// const whatToWatch = mergeIdSets([diffIdSet(this.transaction.insertSet, this.transaction.deleteSet), diffIdSet(this.transaction.deleteSet, this.transaction.insertSet)])
// const genericDelta = this.target.getDelta(am, whatToWatch)
// if (!d.equals(genericDelta)) {
// console.log(d.toJSON())
// console.log(genericDelta.toJSON())
// debugger
// const d2 = this.target.getDelta(am, whatToWatch)
// throw new Error('should match', d2)
// }
// return d
// */
return this.target.getDelta(am, whatToWatch)
}
/**
@@ -966,34 +795,26 @@ export class YText extends AbstractType {
* Apply a {@link Delta} on this shared YText type.
*
* @param {Array<any> | delta.Delta} delta The changes to apply on this element.
* @param {object} opts
* @param {boolean} [opts.sanitize] Sanitize input delta. Removes ending newlines if set to true.
*
* @param {AbstractAttributionManager} am
*
* @public
*/
applyDelta (delta, { sanitize = true } = {}) {
applyDelta (delta, am = noAttributionsManager) {
if (this.doc !== null) {
transact(this.doc, transaction => {
/**
* @type {Array<any>}
*/
const deltaOps = /** @type {Array<any>} */ (/** @type {delta.Delta} */ (delta).ops instanceof Array ? /** @type {delta.Delta} */ (delta).ops : delta)
const currPos = new ItemTextListPosition(null, this._start, 0, new Map())
const currPos = new ItemTextListPosition(null, this._start, 0, new Map(), am)
for (let i = 0; i < deltaOps.length; i++) {
const op = deltaOps[i]
if (op.insert !== undefined) {
// Quill assumes that the content starts with an empty paragraph.
// Yjs/Y.Text assumes that it starts empty. We always hide that
// there is a newline at the end of the content.
// If we omit this step, clients will see a different number of
// paragraphs, but nothing bad will happen.
const ins = (!sanitize && typeof op.insert === 'string' && i === deltaOps.length - 1 && currPos.right === null && op.insert.slice(-1) === '\n') ? op.insert.slice(0, -1) : op.insert
if (typeof ins !== 'string' || ins.length > 0) {
insertText(transaction, this, currPos, ins, op.attributes || {})
if (op.insert.length > 0 || typeof op.insert !== 'string') {
insertText(transaction, this, currPos, op.insert, op.attributes || {})
}
} else if (op.retain !== undefined) {
formatText(transaction, this, currPos, op.retain, op.attributes || {})
currPos.formatText(transaction, this, op.retain, op.attributes || {})
} else if (op.delete !== undefined) {
deleteText(transaction, currPos, op.delete)
}
@@ -1182,7 +1003,11 @@ export class YText extends AbstractType {
if (renderContent) {
d.usedAttributes = currentAttributes
usingCurrentAttributes = true
d.insert(c.content.getContent()[0], null, attribution)
if (!retainOnly) {
d.insert(c.content.getContent()[0], null, attribution)
} else {
d.retain(c.content.getLength(), null, attribution)
}
} else if (renderDelete) {
d.delete(1)
} else if (retainContent) {
@@ -1195,7 +1020,11 @@ export class YText extends AbstractType {
if (renderContent) {
d.usedAttributes = currentAttributes
usingCurrentAttributes = true
d.insert(/** @type {ContentString} */ (c.content).str, null, attribution)
if (!retainOnly) {
d.insert(/** @type {ContentString} */ (c.content).str, null, attribution)
} else {
d.retain(/** @type {ContentString} */ (c.content).str.length, null, attribution)
}
} else if (renderDelete) {
d.delete(c.content.getLength())
} else if (retainContent) {
@@ -1391,7 +1220,7 @@ export class YText extends AbstractType {
if (pos.right === null) {
return
}
formatText(transaction, this, pos, length, attributes)
pos.formatText(transaction, this, length, attributes)
})
} else {
/** @type {Array<function>} */ (this._pending).push(() => this.format(index, length, attributes))

View File

@@ -5,14 +5,14 @@ import {
createDeleteSetFromStructStore,
createIdMapFromIdSet,
ContentDeleted,
Snapshot, Doc, AbstractContent, IdMap, // eslint-disable-line
Item, Snapshot, Doc, AbstractContent, IdMap, // eslint-disable-line
insertIntoIdMap,
insertIntoIdSet,
diffIdMap,
createIdMap,
createAttributionItem,
mergeIdMaps,
createID
createID,
} from '../internals.js'
import * as error from 'lib0/error'
@@ -106,6 +106,17 @@ export class AbstractAttributionManager {
error.methodUnimplemented()
}
/**
* Calculate the length of the attributed content. This is used by iterators that walk through the
* content.
*
* @param {Item} _item
* @return {number}
*/
contentLength (_item) {
error.methodUnimplemented()
}
destroy () {}
}
@@ -145,6 +156,18 @@ export class TwosetAttributionManager {
}
})
}
/**
* @param {Item} item
* @return {number}
*/
contentLength (item) {
if (!item.deleted) {
return item.length
} else {
return this.deletes.sliceId(item.id, item.length).reduce((len, s) => s.attrs != null ? len + s.len : len, 0)
}
}
}
/**
@@ -168,6 +191,14 @@ export class NoAttributionsManager {
contents.push(new AttributedContent(content, deleted, null, shouldRender))
}
}
/**
* @param {Item} item
* @return {number}
*/
contentLength (item) {
return item.deleted ? 0 : item.length
}
}
export const noAttributionsManager = new NoAttributionsManager()
@@ -266,6 +297,18 @@ export class DiffAttributionManager {
}
})
}
/**
* @param {Item} item
* @return {number}
*/
contentLength (item) {
if (!item.deleted) {
return item.length
} else {
return this.deletes.sliceId(item.id, item.length).reduce((len, s) => s.attrs != null ? len + s.len : len, 0)
}
}
}
/**
@@ -331,6 +374,14 @@ export class SnapshotAttributionManager {
}
})
}
/**
* @param {Item} item
* @return {number}
*/
contentLength (item) {
return this.attrs.sliceId(item.id, item.length).reduce((len, s) => s.attrs != null ? len + s.len : len, 0)
}
}
/**

View File

@@ -32,3 +32,39 @@ export const testAttributedEvents = _tc => {
ytext.insert(11, '!')
t.assert(calledObserver)
}
/**
* @param {t.TestCase} _tc
*/
export const testInsertionsMindingAttributedContent = _tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText()
ytext.insert(0, 'hello world')
const v1 = Y.cloneDoc(ydoc)
ydoc.transact(() => {
ytext.delete(6, 5)
})
let am = Y.createAttributionManagerFromDiff(v1, ydoc)
const c1 = ytext.getDelta(am)
t.compare(c1, delta.createTextDelta().insert('hello ').insert('world', null, { delete: [] }))
ytext.applyDelta(delta.createTextDelta().retain(11).insert('content'), am)
t.assert(ytext.toString() === 'hello content')
}
/**
* @param {t.TestCase} _tc
*/
export const testInsertionsIntoAttributedContent = _tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText()
ytext.insert(0, 'hello ')
const v1 = Y.cloneDoc(ydoc)
ydoc.transact(() => {
ytext.insert(6, 'word')
})
let am = Y.createAttributionManagerFromDiff(v1, ydoc)
const c1 = ytext.getDelta(am)
t.compare(c1, delta.createTextDelta().insert('hello ').insert('word', null, { insert: [] }))
ytext.applyDelta(delta.createTextDelta().retain(9).insert('l'), am)
t.assert(ytext.toString() === 'hello world')
}