/** * @module types */ import { ItemEmbed } from '../structs/ItemEmbed.js' import { ItemString } from '../structs/ItemString.js' import { ItemFormat } from '../structs/ItemFormat.js' import { logItemHelper } from '../protocols/syncProtocol.js' import { YArrayEvent, YArray } from './YArray.js' /** * @private */ const integrateItem = (item, parent, y, left, right) => { item._origin = left item._left = left item._right = right item._right_origin = right item._parent = parent if (y !== null) { item._integrate(y) } else if (left === null) { parent._start = item } else { left._right = item } } /** * @private */ const findNextPosition = (currentAttributes, parent, left, right, count) => { while (right !== null && count > 0) { switch (right.constructor) { case ItemEmbed: case ItemString: const rightLen = right._deleted ? 0 : (right._length - 1) if (count <= rightLen) { right = right._splitAt(parent._y, count) left = right._left return [left, right, currentAttributes] } if (right._deleted === false) { count -= right._length } break case ItemFormat: if (right._deleted === false) { updateCurrentAttributes(currentAttributes, right) } break } left = right right = right._right } return [left, right, currentAttributes] } /** * @private */ const findPosition = (parent, index) => { let currentAttributes = new Map() let left = null let right = parent._start return findNextPosition(currentAttributes, parent, left, right, index) } /** * Negate applied formats * * @private */ const insertNegatedAttributes = (y, parent, left, right, negatedAttributes) => { // check if we really need to remove attributes while ( right !== null && ( right._deleted === true || ( right.constructor === ItemFormat && (negatedAttributes.get(right.key) === right.value) ) ) ) { if (right._deleted === false) { negatedAttributes.delete(right.key) } left = right right = right._right } for (let [key, val] of negatedAttributes) { let format = new ItemFormat() format.key = key format.value = val integrateItem(format, parent, y, left, right) left = format } return [left, right] } /** * @private */ const updateCurrentAttributes = (currentAttributes, item) => { const value = item.value const key = item.key if (value === null) { currentAttributes.delete(key) } else { currentAttributes.set(key, value) } } /** * @private */ 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 === true) { // continue } else if (right.constructor === ItemFormat && (attributes[right.key] || null) === right.value) { // found a format, update currentAttributes and continue updateCurrentAttributes(currentAttributes, right) } else { break } left = right right = right._right } return [left, right] } /** * @private */ const insertAttributes = (y, parent, left, right, attributes, currentAttributes) => { 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) let format = new ItemFormat() format.key = key format.value = val integrateItem(format, parent, y, left, right) left = format } } return [left, right, negatedAttributes] } /** * @private */ const insertText = (y, text, parent, left, right, currentAttributes, attributes) => { for (let [key] of currentAttributes) { if (attributes[key] === undefined) { attributes[key] = null } } [left, right] = minimizeAttributeChanges(left, right, currentAttributes, attributes) let negatedAttributes [left, right, negatedAttributes] = insertAttributes(y, parent, left, right, attributes, currentAttributes) // insert content let item if (text.constructor === String) { item = new ItemString() item._content = text } else { item = new ItemEmbed() item.embed = text } integrateItem(item, parent, y, left, right) left = item return insertNegatedAttributes(y, parent, left, right, negatedAttributes) } /** * @private */ const formatText = (y, length, parent, left, right, currentAttributes, attributes) => { [left, right] = minimizeAttributeChanges(left, right, currentAttributes, attributes) let negatedAttributes [left, right, negatedAttributes] = insertAttributes(y, parent, left, right, attributes, currentAttributes) // 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: const attr = attributes[right.key] if (attr !== undefined) { if (attr === right.value) { negatedAttributes.delete(right.key) } else { negatedAttributes.set(right.key, right.value) } right._delete(y) } updateCurrentAttributes(currentAttributes, right) break case ItemEmbed: case ItemString: right._splitAt(y, length) length -= right._length break } } left = right right = right._right } return insertNegatedAttributes(y, parent, left, right, negatedAttributes) } /** * @private */ const deleteText = (y, length, parent, left, right, currentAttributes) => { while (length > 0 && right !== null) { if (right._deleted === false) { switch (right.constructor) { case ItemFormat: updateCurrentAttributes(currentAttributes, right) break case ItemEmbed: case ItemString: right._splitAt(y, length) length -= right._length right._delete(y) 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' } } * ] * } * * @typedef {Array} Delta */ /** * 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 { constructor (ytext, remote, transaction) { super(ytext, remote, transaction) 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. * * @return {Delta} 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 y.transact(() => { let item = this.target._start const delta = [] const added = this.addedElements const removed = this.removedElements this._delta = delta let action = null let attributes = {} // counts added or removed new attributes for retain const currentAttributes = new Map() // saves all current attributes for insert const oldAttributes = new Map() let insert = '' let retain = 0 let deleteLen = 0 const addOp = function 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' 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' } insert += item._content } 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)) { const curVal = currentAttributes.get(item.key) || null if (curVal !== item.value) { if (action === 'retain') { addOp() } if (item.value === (oldAttributes.get(item.key) || null)) { delete attributes[item.key] } else { attributes[item.key] = item.value } } else { item._delete(y) } } else if (removed.has(item)) { oldAttributes.set(item.key, item.value) const curVal = currentAttributes.get(item.key) || null if (curVal !== item.value) { if (action === 'retain') { addOp() } attributes[item.key] = curVal } } else if (item._deleted === false) { oldAttributes.set(item.key, item.value) const attr = attributes[item.key] if (attr !== undefined) { if (attr !== item.value) { if (action === 'retain') { addOp() } if (item.value === null) { attributes[item.key] = item.value } else { delete attributes[item.key] } } else { item._delete(y) } } } if (item._deleted === false) { if (action === 'insert') { addOp() } 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 } } }) } 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 YArray { /** * @param {String} [string] The initial value of the YText. */ constructor (string) { super() if (typeof string === 'string') { const start = new ItemString() start._parent = this start._content = string this._start = start } } /** * Creates YMap Event and calls observers. * * @private */ _callObserver (transaction, parentSubs, remote) { this._callEventHandler(transaction, new YTextEvent(this, remote, 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 () { 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 {Delta} delta The changes to apply on this element. * * @public */ applyDelta (delta) { this._transact(y => { let left = null let right = this._start const currentAttributes = new Map() for (let i = 0; i < delta.length; i++) { let op = delta[i] if (op.insert !== undefined) { ;[left, right] = insertText(y, op.insert, this, left, right, currentAttributes, op.attributes || {}) } else if (op.retain !== undefined) { ;[left, right] = formatText(y, op.retain, this, left, right, currentAttributes, op.attributes || {}) } else if (op.delete !== undefined) { ;[left, right] = deleteText(y, op.delete, this, left, right, currentAttributes) } } }) } /** * Returns the Delta representation of this YText type. * * @return {Delta} The Delta representation of this type. * * @public */ toDelta () { let ops = [] let currentAttributes = new Map() let str = '' /** * @type {any} */ let n = this._start function packStr () { if (str.length > 0) { // pack str with attributes to ops let attributes = {} let addAttributes = false for (let [key, value] of currentAttributes) { addAttributes = true attributes[key] = value } let op = { insert: str } if (addAttributes) { op.attributes = attributes } ops.push(op) str = '' } } while (n !== null) { if (!n._deleted) { switch (n.constructor) { case ItemString: 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 } this._transact(y => { let [left, right, currentAttributes] = findPosition(this, index) insertText(y, text, this, left, right, currentAttributes, 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') } this._transact(y => { let [left, right, currentAttributes] = findPosition(this, index) insertText(y, embed, this, left, right, currentAttributes, 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 } this._transact(y => { let [left, right, currentAttributes] = findPosition(this, index) deleteText(y, length, this, left, right, currentAttributes) }) } /** * 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) { this._transact(y => { let [left, right, currentAttributes] = findPosition(this, index) if (right === null) { return } formatText(y, length, this, left, right, currentAttributes, attributes) }) } // TODO: De-duplicate code. The following code is in every type. /** * Transform this YText to a readable format. * Useful for logging as all Items implement this method. * * @private */ _logString () { return logItemHelper('YText', this) } }