/** * @module types */ import { ItemEmbed } from '../structs/ItemEmbed.js' import { ItemString } from '../structs/ItemString.js' import { ItemFormat } from '../structs/ItemFormat.js' import { YArrayEvent } from './YArray.js' import { ItemType } from '../structs/ItemType.js' // eslint-disable-line import { AbstractType } from './AbstractType.js' import { AbstractItem } from '../structs/AbstractItem.js' // eslint-disable-line import { isVisible, Snapshot } from '../utils/Snapshot.js' // eslint-disable-line import { getItemCleanStart, StructStore } from '../utils/StructStore.js' // eslint-disable-line import { Transaction, nextID } from '../utils/Transaction.js' // eslint-disable-line import { createID } from '../utils/ID.js' import * as decoding from 'lib0/decoding.js' // eslint-disable-line /** * @private * @param {Transaction} transaction * @param {StructStore} store * @param {Map} currentAttributes * @param {AbstractItem|null} left * @param {AbstractItem|null} right * @param {number} count * @return {{left:AbstractItem|null,right:AbstractItem|null,currentAttributes:Map}} */ const findNextPosition = (transaction, store, currentAttributes, left, right, count) => { while (right !== null && count > 0) { switch (right.constructor) { case ItemEmbed: case ItemString: if (!right.deleted) { if (count < right.length) { right = getItemCleanStart(store, transaction, createID(right.id.client, right.id.clock + count)) left = right.left count = 0 } else { count -= right.length } } break case ItemFormat: if (!right.deleted) { // @ts-ignore right is ItemFormat updateCurrentAttributes(currentAttributes, right) } break } left = right right = right.right } return { left, right, currentAttributes } } /** * @private * @param {Transaction} transaction * @param {StructStore} store * @param {AbstractType} parent * @param {number} index * @return {{left:AbstractItem|null,right:AbstractItem|null,currentAttributes:Map}} */ const findPosition = (transaction, store, parent, index) => { let currentAttributes = new Map() let left = null let right = parent._start return findNextPosition(transaction, store, currentAttributes, left, right, index) } /** * Negate applied formats * * @private * @param {Transaction} transaction * @param {AbstractType} parent * @param {AbstractItem|null} left * @param {AbstractItem|null} right * @param {Map} negatedAttributes * @return {{left:AbstractItem|null,right:AbstractItem|null}} */ const insertNegatedAttributes = (transaction, parent, left, right, negatedAttributes) => { // check if we really need to remove attributes while ( right !== null && ( right.deleted === true || ( right.constructor === ItemFormat && // @ts-ignore right is ItemFormat (negatedAttributes.get(right.key) === right.value) ) ) ) { if (!right.deleted) { // @ts-ignore right is ItemFormat negatedAttributes.delete(right.key) } left = right right = right.right } for (let [key, val] of negatedAttributes) { left = new ItemFormat(nextID(transaction), left, right, parent, null, key, val) left.integrate(transaction) } return {left, right} } /** * @private * @param {Map} currentAttributes * @param {ItemFormat} item */ const updateCurrentAttributes = (currentAttributes, item) => { const value = item.value const key = item.key if (value === null) { currentAttributes.delete(key) } else { currentAttributes.set(key, value) } } /** * @private * @param {AbstractItem|null} left * @param {AbstractItem|null} right * @param {Map} currentAttributes * @param {Object} attributes * @return {{left:AbstractItem|null,right:AbstractItem|null}} */ const minimizeAttributeChanges = (left, right, currentAttributes, attributes) => { // go right while attributes[right.key] === right.value (or right is deleted) while (true) { if (right === null) { break } else if (right.deleted) { // continue // @ts-ignore right is ItemFormat } else if (right.constructor === ItemFormat && (attributes[right.key] || null) === right.value) { // found a format, update currentAttributes and continue // @ts-ignore right is ItemFormat updateCurrentAttributes(currentAttributes, right) } else { break } left = right right = right.right } return { left, right } } /** * @private * @param {Transaction} transaction * @param {AbstractType} parent * @param {AbstractItem|null} left * @param {AbstractItem|null} right * @param {Map} currentAttributes * @param {Object} attributes * @return {{left:AbstractItem|null,right:AbstractItem|null,negatedAttributes:Map}} **/ const insertAttributes = (transaction, parent, left, right, currentAttributes, attributes) => { const negatedAttributes = new Map() // insert format-start items for (let key in attributes) { const val = attributes[key] const currentVal = currentAttributes.get(key) if (currentVal !== val) { // save negated attribute (set null if currentVal undefined) negatedAttributes.set(key, currentVal || null) left = new ItemFormat(nextID(transaction), left, right, parent, null, key, val) left.integrate(transaction) } } return { left, right, negatedAttributes } } /** * @private * @param {Transaction} transaction * @param {AbstractType} parent * @param {AbstractItem|null} left * @param {AbstractItem|null} right * @param {Map} currentAttributes * @param {string} text * @param {Object} attributes * @return {{left:AbstractItem|null,right:AbstractItem|null}} **/ const insertText = (transaction, parent, left, right, currentAttributes, text, attributes) => { for (let [key] of currentAttributes) { if (attributes[key] === undefined) { attributes[key] = null } } const minPos = minimizeAttributeChanges(left, right, currentAttributes, attributes) const insertPos = insertAttributes(transaction, parent, minPos.left, minPos.right, currentAttributes, attributes) // insert content if (text.constructor === String) { left = new ItemString(nextID(transaction), insertPos.left, insertPos.right, parent, null, text) } else { left = new ItemEmbed(nextID(transaction), insertPos.left, insertPos.right, parent, null, text) } left.integrate(transaction) return insertNegatedAttributes(transaction, parent, left, insertPos.right, insertPos.negatedAttributes) } /** * @private * @param {Transaction} transaction * @param {AbstractType} parent * @param {AbstractItem|null} left * @param {AbstractItem|null} right * @param {Map} currentAttributes * @param {number} length * @param {Object} attributes * @return {{left:AbstractItem|null,right:AbstractItem|null}} */ const formatText = (transaction, parent, left, right, currentAttributes, length, attributes) => { const minPos = minimizeAttributeChanges(left, right, currentAttributes, attributes) const insertPos = insertAttributes(transaction, parent, minPos.left, minPos.right, currentAttributes, attributes) const negatedAttributes = insertPos.negatedAttributes left = insertPos.left right = insertPos.right // iterate until first non-format or null is found // delete all formats with attributes[format.key] != null while (length > 0 && right !== null) { if (right.deleted === false) { switch (right.constructor) { case ItemFormat: // @ts-ignore right is ItemFormat const attr = attributes[right.key] if (attr !== undefined) { // @ts-ignore right is ItemFormat if (attr === right.value) { // @ts-ignore right is ItemFormat negatedAttributes.delete(right.key) } else { // @ts-ignore right is ItemFormat negatedAttributes.set(right.key, right.value) } right.delete(transaction) } // @ts-ignore right is ItemFormat updateCurrentAttributes(currentAttributes, right) break case ItemEmbed: case ItemString: if (length < right.length) { getItemCleanStart(transaction.y.store, transaction, createID(right.id.client, right.id.clock + length)) } length -= right.length break } } left = right right = right.right } return insertNegatedAttributes(transaction, parent, left, right, negatedAttributes) } /** * @private * @param {Transaction} transaction * @param {AbstractType} parent * @param {AbstractItem|null} left * @param {AbstractItem|null} right * @param {Map} currentAttributes * @param {number} length * @return {{left:AbstractItem|null,right:AbstractItem|null}} */ const deleteText = (transaction, parent, left, right, currentAttributes, length) => { while (length > 0 && right !== null) { if (right.deleted === false) { switch (right.constructor) { case ItemFormat: // @ts-ignore right is ItemFormat updateCurrentAttributes(currentAttributes, right) break case ItemEmbed: case ItemString: if (length < right.length) { getItemCleanStart(transaction.y.store, transaction, createID(right.id.client, right.id.clock + length)) } length -= right.length right.delete(transaction) break } } left = right right = right.right } return { left, right } } // TODO: In the quill delta representation we should also use the format {ops:[..]} /** * The Quill Delta format represents changes on a text document with * formatting information. For mor information visit {@link https://quilljs.com/docs/delta/|Quill Delta} * * @example * { * ops: [ * { insert: 'Gandalf', attributes: { bold: true } }, * { insert: ' the ' }, * { insert: 'Grey', attributes: { color: '#cccccc' } } * ] * } * */ /** * Attributes that can be assigned to a selection of text. * * @example * { * bold: true, * font-size: '40px' * } * * @typedef {Object} TextAttributes */ /** * Event that describes the changes on a YText type. * * @private */ class YTextEvent extends YArrayEvent { /** * @param {AbstractType} ytext * @param {Transaction} transaction */ constructor (ytext, transaction) { super(ytext, transaction) /** * @type {Array<{delete:number|undefined,retain:number|undefined,insert:string|undefined,attributes:Object}>|null} */ this._delta = null } // TODO: Should put this in a separate function. toDelta shouldn't be included // in every Yjs distribution /** * Compute the changes in the delta format. * * @type {Array<{delete:number|undefined,retain:number|undefined,insert:string|undefined,attributes:Object}>} A {@link https://quilljs.com/docs/delta/|Quill Delta}) that * represents the changes on the document. * * @public */ get delta () { if (this._delta === null) { const y = this.target._y // @ts-ignore y.transact(transaction => { /** * @type {Array<{delete:number|undefined,retain:number|undefined,insert:string|undefined,attributes:Object}>} */ const delta = [] const added = this.addedElements const removed = this.removedElements const currentAttributes = new Map() // saves all current attributes for insert const oldAttributes = new Map() let item = this.target._start /** * @type {string?} */ let action = null /** * @type {Object} */ let attributes = {} // counts added or removed new attributes for retain let insert = '' let retain = 0 let deleteLen = 0 this._delta = delta const addOp = () => { if (action !== null) { /** * @type {any} */ let op switch (action) { case 'delete': op = { delete: deleteLen } deleteLen = 0 break case 'insert': op = { insert } if (currentAttributes.size > 0) { op.attributes = {} for (let [key, value] of currentAttributes) { if (value !== null) { op.attributes[key] = value } } } insert = '' break case 'retain': op = { retain } if (Object.keys(attributes).length > 0) { op.attributes = {} for (let key in attributes) { op.attributes[key] = attributes[key] } } retain = 0 break } delta.push(op) action = null } } while (item !== null) { switch (item.constructor) { case ItemEmbed: if (added.has(item)) { addOp() action = 'insert' // @ts-ignore item is ItemFormat insert = item.embed addOp() } else if (removed.has(item)) { if (action !== 'delete') { addOp() action = 'delete' } deleteLen += 1 } else if (item.deleted === false) { if (action !== 'retain') { addOp() action = 'retain' } retain += 1 } break case ItemString: if (added.has(item)) { if (action !== 'insert') { addOp() action = 'insert' } // @ts-ignore insert += item.string } else if (removed.has(item)) { if (action !== 'delete') { addOp() action = 'delete' } deleteLen += item.length } else if (item.deleted === false) { if (action !== 'retain') { addOp() action = 'retain' } retain += item.length } break case ItemFormat: if (added.has(item)) { // @ts-ignore item is ItemFormat const curVal = currentAttributes.get(item.key) || null // @ts-ignore item is ItemFormat if (curVal !== item.value) { if (action === 'retain') { addOp() } // @ts-ignore item is ItemFormat if (item.value === (oldAttributes.get(item.key) || null)) { // @ts-ignore item is ItemFormat delete attributes[item.key] } else { // @ts-ignore item is ItemFormat attributes[item.key] = item.value } } else { item.delete(transaction) } } else if (removed.has(item)) { // @ts-ignore item is ItemFormat oldAttributes.set(item.key, item.value) // @ts-ignore item is ItemFormat const curVal = currentAttributes.get(item.key) || null // @ts-ignore item is ItemFormat if (curVal !== item.value) { if (action === 'retain') { addOp() } // @ts-ignore item is ItemFormat attributes[item.key] = curVal } } else if (item.deleted === false) { // @ts-ignore item is ItemFormat oldAttributes.set(item.key, item.value) // @ts-ignore item is ItemFormat const attr = attributes[item.key] if (attr !== undefined) { // @ts-ignore item is ItemFormat if (attr !== item.value) { if (action === 'retain') { addOp() } // @ts-ignore item is ItemFormat if (item.value === null) { // @ts-ignore item is ItemFormat attributes[item.key] = item.value } else { // @ts-ignore item is ItemFormat delete attributes[item.key] } } else { item.delete(transaction) } } } if (item.deleted === false) { if (action === 'insert') { addOp() } // @ts-ignore item is ItemFormat updateCurrentAttributes(currentAttributes, item) } break } item = item.right } addOp() while (this._delta.length > 0) { let lastOp = this._delta[this._delta.length - 1] if (lastOp.retain !== undefined && lastOp.attributes === undefined) { // retain delta's if they don't assign attributes this._delta.pop() } else { break } } }) } // @ts-ignore _delta is defined above return this._delta } } /** * Type that represents text with formatting information. * * This type replaces y-richtext as this implementation is able to handle * block formats (format information on a paragraph), embeds (complex elements * like pictures and videos), and text formats (**bold**, *italic*). */ export class YText extends AbstractType { /** * @param {String} [string] The initial value of the YText. */ constructor (string) { super() /** * @type {Array?} */ this._prelimContent = string !== undefined ? [string] : [] } get length () { return this._length } /** * @param {Transaction} transaction * @param {ItemType} item */ _integrate (transaction, item) { super._integrate(transaction, item) // @ts-ignore this._prelimContent is still defined this.insert(0, this._prelimContent.join('')) this._prelimContent = null } /** * Creates YTextEvent and calls observers. * @private * * @param {Transaction} transaction * @param {Set} parentSubs Keys changed on this type. `null` if list was modified. */ _callObserver (transaction, parentSubs) { this._callEventHandler(transaction, new YTextEvent(this, transaction)) } toDom () { return document.createTextNode(this.toString()) } /** * Returns the unformatted string representation of this YText type. * * @public */ toString () { let str = '' /** * @type {any} */ let n = this._start while (n !== null) { if (!n._deleted && n._countable) { str += n._content } n = n._right } return str } toDomString () { // @ts-ignore return this.toDelta().map(delta => { const nestedNodes = [] for (let nodeName in delta.attributes) { const attrs = [] for (let 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[i] str += ` ${attr.key}="${attr.value}"` } str += '>' } str += delta.insert for (let i = nestedNodes.length - 1; i >= 0; i--) { str += `` } return str }) } /** * Apply a {@link Delta} on this shared YText type. * * @param {any} delta The changes to apply on this element. * * @public */ applyDelta (delta) { if (this._y !== null) { this._y.transact(transaction => { /** * @type {{left:AbstractItem|null,right:AbstractItem|null}} */ let pos = { left: null, right: this._start } const currentAttributes = new Map() for (let i = 0; i < delta.length; i++) { const op = delta[i] if (op.insert !== undefined) { pos = insertText(transaction, this, pos.left, pos.right, currentAttributes, op.insert, op.attributes || {}) } else if (op.retain !== undefined) { pos = formatText(transaction, this, pos.left, pos.right, currentAttributes, op.retain, op.attributes || {}) } else if (op.delete !== undefined) { pos = deleteText(transaction, this, pos.left, pos.right, currentAttributes, op.delete) } } }) } } /** * Returns the Delta representation of this YText type. * * @param {Snapshot} [snapshot] * @param {Snapshot} [prevSnapshot] * @return {any} The Delta representation of this type. * * @public */ toDelta (snapshot, prevSnapshot) { /** * @type{Array} */ const ops = [] const currentAttributes = new Map() let str = '' /** * @type {any} */ let n = this._start function packStr () { if (str.length > 0) { // pack str with attributes to ops /** * @type {Object} */ let attributes = {} let addAttributes = false for (let [key, value] of currentAttributes) { addAttributes = true attributes[key] = value } /** * @type {Object} */ let op = { insert: str } if (addAttributes) { op.attributes = attributes } ops.push(op) str = '' } } while (n !== null) { if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) { switch (n.constructor) { case ItemString: const cur = currentAttributes.get('ychange') if (snapshot !== undefined && !isVisible(n, snapshot)) { if (cur === undefined || cur.user !== n._id.user || cur.state !== 'removed') { packStr() currentAttributes.set('ychange', { user: n._id.user, state: 'removed' }) } } else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) { if (cur === undefined || cur.user !== n._id.user || cur.state !== 'added') { packStr() currentAttributes.set('ychange', { user: n._id.user, state: 'added' }) } } else if (cur !== undefined) { packStr() currentAttributes.delete('ychange') } str += n._content break case ItemFormat: packStr() updateCurrentAttributes(currentAttributes, n) break } } n = n._right } packStr() return ops } /** * Insert text at a given index. * * @param {number} index The index at which to start inserting. * @param {String} text The text to insert at the specified position. * @param {TextAttributes} attributes Optionally define some formatting * information to apply on the inserted * Text. * @public */ insert (index, text, attributes = {}) { if (text.length <= 0) { return } const y = this._y if (y !== null) { y.transact(transaction => { const {left, right, currentAttributes} = findPosition(transaction, y.store, this, index) insertText(transaction, this, left, right, currentAttributes, text, attributes) }) } } /** * Inserts an embed at a index. * * @param {number} index The index to insert the embed at. * @param {Object} embed The Object that represents the embed. * @param {TextAttributes} attributes Attribute information to apply on the * embed * * @public */ insertEmbed (index, embed, attributes = {}) { if (embed.constructor !== Object) { throw new Error('Embed must be an Object') } const y = this._y if (y !== null) { y.transact(transaction => { const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index) insertText(transaction, this, left, right, currentAttributes, embed, attributes) }) } } /** * Deletes text starting from an index. * * @param {number} index Index at which to start deleting. * @param {number} length The number of characters to remove. Defaults to 1. * * @public */ delete (index, length) { if (length === 0) { return } const y = this._y if (y !== null) { y.transact(transaction => { const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index) deleteText(transaction, this, left, right, currentAttributes, length) }) } } /** * Assigns properties to a range of text. * * @param {number} index The position where to start formatting. * @param {number} length The amount of characters to assign properties to. * @param {TextAttributes} attributes Attribute information to apply on the * text. * * @public */ format (index, length, attributes) { const y = this._y if (y !== null) { y.transact(transaction => { let { left, right, currentAttributes } = findPosition(transaction, y.store, this, index) if (right === null) { return } formatText(transaction, this, left, right, currentAttributes, length, attributes) }) } } } /** * @param {decoding.Decoder} decoder * @return {YText} */ export const readYText = decoder => new YText()