diff --git a/README.md b/README.md index 01eea0e1..d19a48b0 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,8 @@ necessary.

const yarray = new Y.Array()
+ parent:Y.AbstractType|null +
insert(index:number, content:Array<object|boolean|Array|string|number|Uint8Array|Y.Type>)
Insert content at index. Note that content is an array of elements. @@ -313,6 +315,8 @@ or any of its children.

const ymap = new Y.Map()
+ parent:Y.AbstractType|null +
get(key:string):object|boolean|string|number|Uint8Array|Y.Type
set(key:string, value:object|boolean|string|number|Uint8Array|Y.Type) @@ -392,6 +396,8 @@ YTextEvents compute changes as deltas.

const ytext = new Y.Text()
+ parent:Y.AbstractType|null +
insert(index:number, content:string, [formattingAttributes:Object<string,string>])
Insert a string at index and assign formatting attributes to it. @@ -450,6 +456,10 @@ or any of its children.

const yxml = new Y.XmlFragment()
+ parent:Y.AbstractType|null +
+ firstChild:Y.XmlElement|Y.XmlText|null +
insert(index:number, content:Array<Y.XmlElement|Y.XmlText>)
delete(index:number, length:number) @@ -505,6 +515,14 @@ content and be actually XML compliant.

const yxml = new Y.XmlElement()
+ parent:Y.AbstractType|null +
+ firstChild:Y.XmlElement|Y.XmlText|null +
+ nextSibling:Y.XmlElement|Y.XmlText|null +
+ prevSibling:Y.XmlElement|Y.XmlText|null +
insert(index:number, content:Array<Y.XmlElement|Y.XmlText>)
delete(index:number, length:number) diff --git a/src/types/AbstractType.js b/src/types/AbstractType.js index c3513d68..3b6aa581 100644 --- a/src/types/AbstractType.js +++ b/src/types/AbstractType.js @@ -287,6 +287,13 @@ export class AbstractType { this._searchMarker = null } + /** + * @return {AbstractType|null} + */ + get parent () { + return this._item ? /** @type {AbstractType} */ (this._item.parent) : null + } + /** * Integrate this type into the Yjs instance. * diff --git a/src/types/YText.js b/src/types/YText.js index 2c129385..a12d0747 100644 --- a/src/types/YText.js +++ b/src/types/YText.js @@ -21,6 +21,10 @@ import { iterateDeletedStructs, iterateStructs, findMarker, + typeMapDelete, + typeMapSet, + typeMapGet, + typeMapGetAll, updateMarkerChanges, ArraySearchMarker, AbstractUpdateDecoder, AbstractUpdateEncoder, ID, Doc, Item, Snapshot, Transaction // eslint-disable-line } from '../internals.js' @@ -512,13 +516,32 @@ export class YTextEvent extends YEvent { /** * @param {YText} ytext * @param {Transaction} transaction + * @param {Set} subs The keys that changed */ - constructor (ytext, transaction) { + constructor (ytext, transaction, subs) { super(ytext, transaction) /** * @type {Array|null} */ this._delta = null + /** + * Whether the children changed. + * @type {Boolean} + * @private + */ + this.childListChanged = false + /** + * Set of all changed attributes. + * @type {Set} + */ + this.keysChanged = new Set() + subs.forEach((sub) => { + if (sub === null) { + this.childListChanged = true + } else { + this.keysChanged.add(sub) + } + }) } /** @@ -779,7 +802,7 @@ export class YText extends AbstractType { */ _callObserver (transaction, parentSubs) { super._callObserver(transaction, parentSubs) - const event = new YTextEvent(this, transaction) + const event = new YTextEvent(this, transaction, parentSubs) const doc = transaction.doc // If a remote change happened, we try to cleanup potential formatting duplicates. if (!transaction.local) { @@ -1111,6 +1134,74 @@ export class YText extends AbstractType { } } + /** + * Removes an attribute. + * + * @note Xml-Text nodes don't have attributes. You can use this feature to assign properties to complete text-blocks. + * + * @param {String} attributeName The attribute name that is to be removed. + * + * @public + */ + removeAttribute (attributeName) { + if (this.doc !== null) { + transact(this.doc, transaction => { + typeMapDelete(transaction, this, attributeName) + }) + } else { + /** @type {Array} */ (this._pending).push(() => this.removeAttribute(attributeName)) + } + } + + /** + * Sets or updates an attribute. + * + * @note Xml-Text nodes don't have attributes. You can use this feature to assign properties to complete text-blocks. + * + * @param {String} attributeName The attribute name that is to be set. + * @param {any} attributeValue The attribute value that is to be set. + * + * @public + */ + setAttribute (attributeName, attributeValue) { + if (this.doc !== null) { + transact(this.doc, transaction => { + typeMapSet(transaction, this, attributeName, attributeValue) + }) + } else { + /** @type {Array} */ (this._pending).push(() => this.setAttribute(attributeName, attributeValue)) + } + } + + /** + * Returns an attribute value that belongs to the attribute name. + * + * @note Xml-Text nodes don't have attributes. You can use this feature to assign properties to complete text-blocks. + * + * @param {String} attributeName The attribute name that identifies the + * queried value. + * @return {any} The queried attribute value. + * + * @public + */ + getAttribute (attributeName) { + return /** @type {any} */ (typeMapGet(this, attributeName)) + } + + /** + * Returns all attribute name/value pairs in a JSON Object. + * + * @note Xml-Text nodes don't have attributes. You can use this feature to assign properties to complete text-blocks. + * + * @param {Snapshot} [snapshot] + * @return {Object} A JSON Object that describes the attributes. + * + * @public + */ + getAttributes (snapshot) { + return typeMapGetAll(this) + } + /** * @param {AbstractUpdateEncoder} encoder */ diff --git a/src/types/YXmlElement.js b/src/types/YXmlElement.js index 0757e1b2..3b64fa51 100644 --- a/src/types/YXmlElement.js +++ b/src/types/YXmlElement.js @@ -8,7 +8,7 @@ import { typeMapGetAll, typeListForEach, YXmlElementRefID, - AbstractType, AbstractUpdateDecoder, AbstractUpdateEncoder, Snapshot, Doc, Item // eslint-disable-line + YXmlText, ContentType, AbstractType, AbstractUpdateDecoder, AbstractUpdateEncoder, Snapshot, Doc, Item // eslint-disable-line } from '../internals.js' /** @@ -28,6 +28,22 @@ export class YXmlElement extends YXmlFragment { this._prelimAttrs = new Map() } + /** + * @type {YXmlElement|YXmlText|null} + */ + get nextSibling () { + const n = this._item ? this._item.next : null + return n ? /** @type {YXmlElement|YXmlText} */ (/** @type {ContentType} */ (n.content).type) : null + } + + /** + * @type {YXmlElement|YXmlText|null} + */ + get prevSibling () { + const n = this._item ? this._item.prev : null + return n ? /** @type {YXmlElement|YXmlText} */ (/** @type {ContentType} */ (n.content).type) : null + } + /** * Integrate this type into the Yjs instance. * diff --git a/src/types/YXmlEvent.js b/src/types/YXmlEvent.js index c41ba3d4..535e55d6 100644 --- a/src/types/YXmlEvent.js +++ b/src/types/YXmlEvent.js @@ -1,7 +1,7 @@ import { YEvent, - YXmlElement, YXmlFragment, Transaction // eslint-disable-line + YXmlText, YXmlElement, YXmlFragment, Transaction // eslint-disable-line } from '../internals.js' /** @@ -9,7 +9,7 @@ import { */ export class YXmlEvent extends YEvent { /** - * @param {YXmlElement|YXmlFragment} target The target on which the event is created. + * @param {YXmlElement|YXmlText|YXmlFragment} target The target on which the event is created. * @param {Set} subs The set of changed attributes. `null` is included if the * child list changed. * @param {Transaction} transaction The transaction instance with wich the @@ -25,7 +25,7 @@ export class YXmlEvent extends YEvent { this.childListChanged = false /** * Set of all changed attributes. - * @type {Set} + * @type {Set} */ this.attributesChanged = new Set() subs.forEach((sub) => { diff --git a/src/types/YXmlFragment.js b/src/types/YXmlFragment.js index 711c9687..21c6a3a0 100644 --- a/src/types/YXmlFragment.js +++ b/src/types/YXmlFragment.js @@ -9,6 +9,7 @@ import { typeListMap, typeListForEach, typeListInsertGenerics, + typeListInsertGenericsAfter, typeListDelete, typeListToArray, YXmlFragmentRefID, @@ -19,6 +20,8 @@ import { AbstractUpdateDecoder, AbstractUpdateEncoder, Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line } from '../internals.js' +import * as error from 'lib0/error.js' + /** * Define the elements to which a set of CSS queries apply. * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors|CSS_Selectors} @@ -130,6 +133,14 @@ export class YXmlFragment extends AbstractType { this._prelimContent = [] } + /** + * @type {YXmlElement|YXmlText|null} + */ + get firstChild () { + const first = this._first + return first ? first.content.getContent()[0] : null + } + /** * Integrate this type into the Yjs instance. * @@ -302,6 +313,32 @@ export class YXmlFragment extends AbstractType { } } + /** + * Inserts new content at an index. + * + * @example + * // Insert character 'a' at position 0 + * xml.insert(0, [new Y.XmlText('text')]) + * + * @param {null|Item|YXmlElement|YXmlText} ref The index to insert content at + * @param {Array} content The array of content + */ + insertAfter (ref, content) { + if (this.doc !== null) { + transact(this.doc, transaction => { + const refItem = (ref && ref instanceof AbstractType) ? ref._item : ref + typeListInsertGenericsAfter(transaction, this, refItem, content) + }) + } else { + const pc = /** @type {Array} */ (this._prelimContent) + const index = ref === null ? 0 : pc.findIndex(el => el === ref) + 1 + if (index === 0 && ref !== null) { + throw error.create('Reference item not found') + } + pc.splice(index, 0, ...content) + } + } + /** * Deletes elements starting from an index. * diff --git a/src/types/YXmlText.js b/src/types/YXmlText.js index 697e31c9..dd8d892d 100644 --- a/src/types/YXmlText.js +++ b/src/types/YXmlText.js @@ -2,7 +2,7 @@ import { YText, YXmlTextRefID, - AbstractUpdateDecoder, AbstractUpdateEncoder // eslint-disable-line + ContentType, YXmlElement, AbstractUpdateDecoder, AbstractUpdateEncoder // eslint-disable-line } from '../internals.js' /** @@ -10,6 +10,22 @@ import { * simple formatting information like bold and italic. */ export class YXmlText extends YText { + /** + * @type {YXmlElement|YXmlText|null} + */ + get nextSibling () { + const n = this._item ? this._item.next : null + return n ? /** @type {YXmlElement|YXmlText} */ (/** @type {ContentType} */ (n.content).type) : null + } + + /** + * @type {YXmlElement|YXmlText|null} + */ + get prevSibling () { + const n = this._item ? this._item.prev : null + return n ? /** @type {YXmlElement|YXmlText} */ (/** @type {ContentType} */ (n.content).type) : null + } + _copy () { return new YXmlText() } diff --git a/src/utils/RelativePosition.js b/src/utils/RelativePosition.js index a4af4af7..dc435359 100644 --- a/src/utils/RelativePosition.js +++ b/src/utils/RelativePosition.js @@ -264,6 +264,7 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc) => { /** * @param {RelativePosition|null} a * @param {RelativePosition|null} b + * @return {boolean} * * @function */ diff --git a/src/utils/UndoManager.js b/src/utils/UndoManager.js index 0710ef11..ce906be3 100644 --- a/src/utils/UndoManager.js +++ b/src/utils/UndoManager.js @@ -119,9 +119,6 @@ const popStackItem = (undoManager, stack, eventType) => { } } result = stackItem - if (result != null) { - undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType }, undoManager]) - } } transaction.changed.forEach((subProps, type) => { // destroy search marker if necessary @@ -130,6 +127,9 @@ const popStackItem = (undoManager, stack, eventType) => { } }) }, undoManager) + if (result != null) { + undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType }, undoManager]) + } return result } diff --git a/src/utils/YEvent.js b/src/utils/YEvent.js index 76be076b..ff4e7339 100644 --- a/src/utils/YEvent.js +++ b/src/utils/YEvent.js @@ -40,6 +40,8 @@ export class YEvent { /** * Computes the path from `y` to the changed type. * + * @todo v14 should standardize on path: Array<{parent, index}> because that is easier to work with. + * * The following property holds: * @example * let type = y diff --git a/tests/y-xml.tests.js b/tests/y-xml.tests.js index 8c2023fc..31513091 100644 --- a/tests/y-xml.tests.js +++ b/tests/y-xml.tests.js @@ -73,3 +73,63 @@ export const testTreewalker = tc => { t.assert(xml0.querySelector('p') === paragraph1, 'querySelector found paragraph1') compare(users) } + +/** + * @param {t.TestCase} tc + */ +export const testYtextAttributes = tc => { + const ydoc = new Y.Doc() + const ytext = /** @type {Y.XmlText} */ (ydoc.get('', Y.XmlText)) + ytext.observe(event => { + t.compare(event.changes.keys.get('test'), { action: 'add', oldValue: undefined }) + }) + ytext.setAttribute('test', 42) + t.compare(ytext.getAttribute('test'), 42) + t.compare(ytext.getAttributes(), { test: 42 }) +} + +/** + * @param {t.TestCase} tc + */ +export const testSiblings = tc => { + const ydoc = new Y.Doc() + const yxml = ydoc.getXmlFragment() + const first = new Y.XmlText() + const second = new Y.XmlElement('p') + yxml.insert(0, [first, second]) + t.assert(first.nextSibling === second) + t.assert(second.prevSibling === first) + t.assert(first.parent === yxml) + t.assert(yxml.parent === null) + t.assert(yxml.firstChild === first) +} + +/** + * @param {t.TestCase} tc + */ +export const testInsertafter = tc => { + const ydoc = new Y.Doc() + const yxml = ydoc.getXmlFragment() + const first = new Y.XmlText() + const second = new Y.XmlElement('p') + const third = new Y.XmlElement('p') + + const deepsecond1 = new Y.XmlElement('span') + const deepsecond2 = new Y.XmlText() + second.insertAfter(null, [deepsecond1]) + second.insertAfter(deepsecond1, [deepsecond2]) + + yxml.insertAfter(null, [first, second]) + yxml.insertAfter(second, [third]) + + t.assert(yxml.length === 3) + t.assert(second.get(0) === deepsecond1) + t.assert(second.get(1) === deepsecond2) + + t.compareArrays(yxml.toArray(), [first, second, third]) + + t.fails(() => { + const el = new Y.XmlElement('p') + el.insertAfter(deepsecond1, [new Y.XmlText()]) + }) +}