restructuring the project

This commit is contained in:
Kevin Jahns
2019-03-01 23:26:40 +01:00
parent f6b4819ae3
commit 75f4a0a5f0
163 changed files with 6423 additions and 10770 deletions

407
src/types/YArray.js Normal file
View File

@@ -0,0 +1,407 @@
/**
* @module types
*/
import { Type } from '../structs/Type.js'
import { ItemJSON } from '../structs/ItemJSON.js'
import { ItemString } from '../structs/ItemString.js'
import * as stringify from 'y-protocols/utils/structStringify.js'
import { YEvent } from '../utils/YEvent.js'
import { Transaction } from '../utils/Transaction.js' // eslint-disable-line
import { Item } from '../structs/Item.js' // eslint-disable-line
import { ItemBinary } from '../structs/ItemBinary.js'
import { isVisible } from '../utils/snapshot.js'
/**
* Event that describes the changes on a YArray
*/
export class YArrayEvent extends YEvent {
/**
* @param {YArray} yarray The changed type
* @param {Boolean} remote Whether the changed was caused by a remote peer
* @param {Transaction} transaction The transaction object
*/
constructor (yarray, remote, transaction) {
super(yarray)
this.remote = remote
this._transaction = transaction
this._addedElements = null
this._removedElements = null
}
/**
* Child elements that were added in this transaction.
*
* @return {Set}
*/
get addedElements () {
if (this._addedElements === null) {
const target = this.target
const transaction = this._transaction
const addedElements = new Set()
transaction.newTypes.forEach(type => {
if (type._parent === target && !transaction.deletedStructs.has(type)) {
addedElements.add(type)
}
})
this._addedElements = addedElements
}
return this._addedElements
}
/**
* Child elements that were removed in this transaction.
*
* @return {Set}
*/
get removedElements () {
if (this._removedElements === null) {
const target = this.target
const transaction = this._transaction
const removedElements = new Set()
transaction.deletedStructs.forEach(struct => {
if (struct._parent === target && !transaction.newTypes.has(struct)) {
removedElements.add(struct)
}
})
this._removedElements = removedElements
}
return this._removedElements
}
}
/**
* A shared Array implementation.
*/
export class YArray extends Type {
constructor () {
super()
this.length = 0
}
/**
* Creates YArray Event and calls observers.
*
* @private
*/
_callObserver (transaction, parentSubs, remote) {
this._callEventHandler(transaction, new YArrayEvent(this, remote, transaction))
}
/**
* Returns the i-th element from a YArray.
*
* @param {number} index The index of the element to return from the YArray
* @return {any}
*/
get (index) {
let n = this._start
while (n !== null) {
if (!n._deleted && n._countable) {
if (index < n._length) {
switch (n.constructor) {
case ItemJSON:
case ItemString:
return n._content[index]
default:
return n
}
}
index -= n._length
}
n = n._right
}
}
/**
* Transforms this YArray to a JavaScript Array.
*
* @param {Object} [snapshot]
* @return {Array}
*/
toArray (snapshot) {
return this.map(c => c, snapshot)
}
/**
* Transforms this Shared Type to a JSON object.
*
* @return {Array}
*/
toJSON () {
return this.map(c => {
if (c instanceof Type) {
return c.toJSON()
}
return c
})
}
/**
* Returns an Array with the result of calling a provided function on every
* element of this YArray.
*
* @param {Function} f Function that produces an element of the new Array
* @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
* @return {Array} A new array with each element being the result of the
* callback function
*/
map (f, snapshot) {
const res = []
this.forEach((c, i) => {
res.push(f(c, i, this))
}, snapshot)
return res
}
/**
* Executes a provided function on once on overy element of this YArray.
*
* @param {Function} f A function to execute on every element of this YArray.
* @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
*/
forEach (f, snapshot) {
let index = 0
let n = this._start
while (n !== null) {
if (isVisible(n, snapshot) && n._countable) {
if (n instanceof Type) {
f(n, index++, this)
} else if (n.constructor === ItemBinary) {
f(n._content, index++, this)
} else {
const content = n._content
const contentLen = content.length
for (let i = 0; i < contentLen; i++) {
index++
f(content[i], index, this)
}
}
}
n = n._right
}
}
[Symbol.iterator] () {
return {
next: function () {
while (this._item !== null && (this._item._deleted || this._item._length <= this._itemElement)) {
// item is deleted or itemElement does not exist (is deleted)
this._item = this._item._right
this._itemElement = 0
}
if (this._item === null) {
return {
done: true
}
}
let content
if (this._item instanceof Type) {
content = this._item
this._item = this._item._right
} else {
content = this._item._content[this._itemElement++]
}
return {
value: content,
done: false
}
},
_item: this._start,
_itemElement: 0,
_count: 0
}
}
/**
* Deletes elements starting from an index.
*
* @param {number} index Index at which to start deleting elements
* @param {number} length The number of elements to remove. Defaults to 1.
*/
delete (index, length = 1) {
this._y.transact(() => {
let item = this._start
let count = 0
while (item !== null && length > 0) {
if (!item._deleted && item._countable) {
if (count <= index && index < count + item._length) {
const diffDel = index - count
item = item._splitAt(this._y, diffDel)
item._splitAt(this._y, length)
length -= item._length
item._delete(this._y)
count += diffDel
} else {
count += item._length
}
}
item = item._right
}
})
if (length > 0) {
throw new Error('Delete exceeds the range of the YArray')
}
}
/**
* Inserts content after an element container.
*
* @private
* @param {Item} left The element container to use as a reference.
* @param {Array<number|string|Object|ArrayBuffer>} content The Array of content to insert (see {@see insert})
*/
insertAfter (left, content) {
this._transact(y => {
let right
if (left === null) {
right = this._start
} else {
right = left._right
}
let prevJsonIns = null
for (let i = 0; i < content.length; i++) {
let c = content[i]
if (typeof c === 'function') {
c = new c() // eslint-disable-line new-cap
}
if (c instanceof Type) {
if (prevJsonIns !== null) {
if (y !== null) {
prevJsonIns._integrate(y)
}
left = prevJsonIns
prevJsonIns = null
}
c._origin = left
c._left = left
c._right = right
c._right_origin = right
c._parent = this
if (y !== null) {
c._integrate(y)
} else if (left === null) {
this._start = c
} else {
left._right = c
}
left = c
} else if (c.constructor === ArrayBuffer) {
if (prevJsonIns !== null) {
if (y !== null) {
prevJsonIns._integrate(y)
}
left = prevJsonIns
prevJsonIns = null
}
const itemBinary = new ItemBinary()
itemBinary._origin = left
itemBinary._left = left
itemBinary._right = right
itemBinary._right_origin = right
itemBinary._parent = this
itemBinary._content = c
if (y !== null) {
itemBinary._integrate(y)
} else if (left === null) {
this._start = itemBinary
} else {
left._right = itemBinary
}
left = itemBinary
} else {
if (prevJsonIns === null) {
prevJsonIns = new ItemJSON()
prevJsonIns._origin = left
prevJsonIns._left = left
prevJsonIns._right = right
prevJsonIns._right_origin = right
prevJsonIns._parent = this
prevJsonIns._content = []
}
prevJsonIns._content.push(c)
}
}
if (prevJsonIns !== null) {
if (y !== null) {
prevJsonIns._integrate(y)
} else if (prevJsonIns._left === null) {
this._start = prevJsonIns
} else {
left._right = prevJsonIns
}
}
})
return content
}
/**
* Inserts new content at an index.
*
* Important: This function expects an array of content. Not just a content
* object. The reason for this "weirdness" is that inserting several elements
* is very efficient when it is done as a single operation.
*
* @example
* // Insert character 'a' at position 0
* yarray.insert(0, ['a'])
* // Insert numbers 1, 2 at position 1
* yarray.insert(2, [1, 2])
*
* @param {number} index The index to insert content at.
* @param {Array<number|string|ArrayBuffer|Type>} content The array of content
*/
insert (index, content) {
this._transact(() => {
let left = null
let right = this._start
let count = 0
const y = this._y
while (right !== null) {
const rightLen = right._deleted ? 0 : (right._length - 1)
if (count <= index && index <= count + rightLen) {
const splitDiff = index - count
right = right._splitAt(y, splitDiff)
left = right._left
count += splitDiff
break
}
if (!right._deleted) {
count += right._length
}
left = right
right = right._right
}
if (index > count) {
throw new Error('Index exceeds array range!')
}
this.insertAfter(left, content)
})
}
/**
* Appends content to this YArray.
*
* @param {Array<number|string|ArrayBuffer|Type>} content Array of content to append.
*/
push (content) {
let n = this._start
let lastUndeleted = null
while (n !== null) {
if (!n._deleted) {
lastUndeleted = n
}
n = n._right
}
this.insertAfter(lastUndeleted, content)
}
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () {
return stringify.logItemHelper('YArray', this, `start:${stringify.stringifyItemID(this._start)}"`)
}
}

213
src/types/YMap.js Normal file
View File

@@ -0,0 +1,213 @@
/**
* @module types
*/
import { Item } from '../structs/Item.js'
import { Type } from '../structs/Type.js'
import { ItemJSON } from '../structs/ItemJSON.js'
import * as stringify from 'y-protocols/utils/structStringify.js'
import { YEvent } from '../utils/YEvent.js'
import { ItemBinary } from '../structs/ItemBinary.js'
import { isVisible } from '../utils/snapshot.js'
/**
* Event that describes the changes on a YMap.
*/
export class YMapEvent extends YEvent {
/**
* @param {YMap} ymap The YArray that changed.
* @param {Set<any>} subs The keys that changed.
* @param {boolean} remote Whether the change was created by a remote peer.
*/
constructor (ymap, subs, remote) {
super(ymap)
this.keysChanged = subs
this.remote = remote
}
}
/**
* A shared Map implementation.
*/
export class YMap extends Type {
/**
* Creates YMap Event and calls observers.
*
* @private
*/
_callObserver (transaction, parentSubs, remote) {
this._callEventHandler(transaction, new YMapEvent(this, parentSubs, remote))
}
/**
* Transforms this Shared Type to a JSON object.
*
* @return {Object}
*/
toJSON () {
const map = {}
for (let [key, item] of this._map) {
if (!item._deleted) {
let res
if (item instanceof Type) {
if (item.toJSON !== undefined) {
res = item.toJSON()
} else {
res = item.toString()
}
} else if (item.constructor === ItemBinary) {
res = item._content
} else {
res = item._content[0]
}
map[key] = res
}
}
return map
}
/**
* Returns the keys for each element in the YMap Type.
*
* @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
* @return {Array}
*/
keys (snapshot) {
// TODO: Should return either Iterator or Set!
let keys = []
if (snapshot === undefined) {
for (let [key, value] of this._map) {
if (value._deleted) {
keys.push(key)
}
}
} else {
this._map.forEach((_, key) => {
if (YMap.prototype.has.call(this, key, snapshot)) {
keys.push(key)
}
})
}
return keys
}
/**
* Remove a specified element from this YMap.
*
* @param {string} key The key of the element to remove.
*/
delete (key) {
this._transact((y) => {
let c = this._map.get(key)
if (y !== null && c !== undefined) {
c._delete(y)
}
})
}
/**
* Adds or updates an element with a specified key and value.
*
* @param {string} key The key of the element to add to this YMap
* @param {Object | string | number | Type | ArrayBuffer } value The value of the element to add
*/
set (key, value) {
this._transact(y => {
const old = this._map.get(key) || null
if (old !== null) {
if (
old.constructor === ItemJSON &&
!old._deleted && old._content[0] === value
) {
// Trying to overwrite with same value
// break here
return value
}
if (y !== null) {
old._delete(y)
}
}
let v
if (typeof value === 'function') {
v = new value() // eslint-disable-line new-cap
value = v
} else if (value instanceof Item) {
v = value
} else if (value.constructor === ArrayBuffer) {
v = new ItemBinary()
v._content = value
} else {
v = new ItemJSON()
v._content = [value]
}
v._right = old
v._right_origin = old
v._parent = this
v._parentSub = key
if (y !== null) {
v._integrate(y)
} else {
this._map.set(key, v)
}
})
return value
}
/**
* Returns a specified element from this YMap.
*
* @param {string} key The key of the element to return.
* @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
*/
get (key, snapshot) {
let v = this._map.get(key)
if (v === undefined) {
return undefined
}
if (snapshot !== undefined) {
// iterate until found element that exists
while (!snapshot.sm.has(v._id.user) || v._id.clock >= snapshot.sm.get(v._id.user)) {
v = v._right
}
}
if (isVisible(v, snapshot)) {
if (v instanceof Type) {
return v
} else if (v.constructor === ItemBinary) {
return v._content
} else {
return v._content[v._content.length - 1]
}
}
}
/**
* Returns a boolean indicating whether the specified key exists or not.
*
* @param {string} key The key to test.
* @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
*/
has (key, snapshot) {
let v = this._map.get(key)
if (v === undefined) {
return false
}
if (snapshot !== undefined) {
// iterate until found element that exists
while (!snapshot.sm.has(v._id.user) || v._id.clock >= snapshot.sm.get(v._id.user)) {
v = v._right
}
}
return isVisible(v, snapshot)
}
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () {
return stringify.logItemHelper('YMap', this, `mapSize:${this._map.size}`)
}
}

725
src/types/YText.js Normal file
View File

@@ -0,0 +1,725 @@
/**
* @module types
*/
import { ItemEmbed } from '../structs/ItemEmbed.js'
import { ItemString } from '../structs/ItemString.js'
import { ItemFormat } from '../structs/ItemFormat.js'
import * as stringify from 'y-protocols/utils/structStringify.js'
import { YArrayEvent, YArray } from './YArray.js'
import { isVisible } from '../utils/snapshot.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<Object>} 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 += `</${nestedNodes[i].nodeName}>`
}
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.
*
* @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
* @param {import('../protocols/history.js').HistorySnapshot} [prevSnapshot]
* @return {Delta} The Delta representation of this type.
*
* @public
*/
toDelta (snapshot, prevSnapshot) {
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 (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
}
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 stringify.logItemHelper('YText', this)
}
}

435
src/types/YXmlElement.js Normal file
View File

@@ -0,0 +1,435 @@
/**
* @module types
*/
import { YMap } from './YMap.js'
import * as encoding from 'funlib/encoding.js'
import * as decoding from 'funlib/decoding.js'
import { Y } from '../utils/Y.js' // eslint-disable-line
import { YArray } from './YArray.js'
import { YXmlEvent } from './YXmlEvent.js'
import * as stringify from 'y-protocols/utils/structStringify.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}
*
* @example
* query = '.classSelector'
* query = 'nodeSelector'
* query = '#idSelector'
*
* @typedef {string} CSS_Selector
*/
/**
* Represents a subset of the nodes of a YXmlElement / YXmlFragment and a
* position within them.
*
* Can be created with {@link YXmlFragment#createTreeWalker}
*
* @public
*/
export class YXmlTreeWalker {
constructor (root, f) {
this._filter = f || (() => true)
this._root = root
this._currentNode = root
this._firstCall = true
}
[Symbol.iterator] () {
return this
}
/**
* Get the next node.
*
* @return {YXmlElement} The next node.
*
* @public
*/
next () {
let n = this._currentNode
if (this._firstCall) {
this._firstCall = false
if (!n._deleted && this._filter(n)) {
return { value: n, done: false }
}
}
do {
if (!n._deleted && (n.constructor === YXmlElement || n.constructor === YXmlFragment) && n._start !== null) {
// walk down in the tree
n = n._start
} else {
// walk right or up in the tree
while (n !== this._root) {
if (n._right !== null) {
n = n._right
break
}
n = n._parent
}
if (n === this._root) {
n = null
}
}
if (n === this._root) {
break
}
} while (n !== null && (n._deleted || !this._filter(n)))
this._currentNode = n
if (n === null) {
return { done: true }
} else {
return { value: n, done: false }
}
}
}
/**
* Dom filter function.
*
* @callback domFilter
* @param {string} nodeName The nodeName of the element
* @param {Map} attributes The map of attributes.
* @return {boolean} Whether to include the Dom node in the YXmlElement.
*/
/**
* 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}
*
* @example
* query = '.classSelector'
* query = 'nodeSelector'
* query = '#idSelector'
*
* @typedef {string} CSS_Selector
*//**
* @module types
*/
/**
* Represents a list of {@link YXmlElement}.and {@link YXmlText} types.
* A YxmlFragment is similar to a {@link YXmlElement}, but it does not have a
* nodeName and it does not have attributes. Though it can be bound to a DOM
* element - in this case the attributes and the nodeName are not shared.
*
* @public
*/
export class YXmlFragment extends YArray {
/**
* Create a subtree of childNodes.
*
* @example
* const walker = elem.createTreeWalker(dom => dom.nodeName === 'div')
* for (let node in walker) {
* // `node` is a div node
* nop(node)
* }
*
* @param {Function} filter Function that is called on each child element and
* returns a Boolean indicating whether the child
* is to be included in the subtree.
* @return {YXmlTreeWalker} A subtree and a position within it.
*
* @public
*/
createTreeWalker (filter) {
return new YXmlTreeWalker(this, filter)
}
/**
* Returns the first YXmlElement that matches the query.
* Similar to DOM's {@link querySelector}.
*
* Query support:
* - tagname
* TODO:
* - id
* - attribute
*
* @param {CSS_Selector} query The query on the children.
* @return {YXmlElement} The first element that matches the query or null.
*
* @public
*/
querySelector (query) {
query = query.toUpperCase()
const iterator = new YXmlTreeWalker(this, element => element.nodeName === query)
const next = iterator.next()
if (next.done) {
return null
} else {
return next.value
}
}
/**
* Returns all YXmlElements that match the query.
* Similar to Dom's {@link querySelectorAll}.
*
* TODO: Does not yet support all queries. Currently only query by tagName.
*
* @param {CSS_Selector} query The query on the children
* @return {Array<YXmlElement>} The elements that match this query.
*
* @public
*/
querySelectorAll (query) {
query = query.toUpperCase()
return Array.from(new YXmlTreeWalker(this, element => element.nodeName === query))
}
/**
* Creates YArray Event and calls observers.
*
* @private
*/
_callObserver (transaction, parentSubs, remote) {
this._callEventHandler(transaction, new YXmlEvent(this, parentSubs, remote, transaction))
}
toString () {
return this.toDomString()
}
/**
* Get the string representation of all the children of this YXmlFragment.
*
* @return {string} The string representation of all children.
*/
toDomString () {
return this.map(xml => xml.toDomString()).join('')
}
/**
* Creates a Dom Element that mirrors this YXmlElement.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object<string, any>} [hooks={}] Optional property to customize how hooks
* are presented in the DOM
* @param {DomBinding} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type.
* @return {DocumentFragment} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDom (_document = document, hooks = {}, binding) {
const fragment = _document.createDocumentFragment()
if (binding !== undefined) {
binding._createAssociation(fragment, this)
}
this.forEach(xmlType => {
fragment.insertBefore(xmlType.toDom(_document, hooks, binding), null)
})
return fragment
}
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () {
return stringify.logItemHelper('YXml', this)
}
}
/**
* An YXmlElement imitates the behavior of a
* {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}.
*
* * An YXmlElement has attributes (key value pairs)
* * An YXmlElement has childElements that must inherit from YXmlElement
*/
export class YXmlElement extends YXmlFragment {
constructor (nodeName = 'UNDEFINED') {
super()
this.nodeName = nodeName.toUpperCase()
}
/**
* Creates an Item with the same effect as this Item (without position effect)
*
* @private
*/
_copy () {
let struct = super._copy()
struct.nodeName = this.nodeName
return struct
}
/**
* Read the next Item in a Decoder and fill this Item with the read data.
*
* This is called when data is received from a remote peer.
*
* @private
* @param {Y} y The Yjs instance that this Item belongs to.
* @param {decoding.Decoder} decoder The decoder object to read data from.
*/
_fromBinary (y, decoder) {
const missing = super._fromBinary(y, decoder)
this.nodeName = decoding.readVarString(decoder)
return missing
}
/**
* Transform the properties of this type to binary and write it to an
* BinaryEncoder.
*
* This is called when this Item is sent to a remote peer.
*
* @private
* @param {encoding.Encoder} encoder The encoder to write data to.
*/
_toBinary (encoder) {
super._toBinary(encoder)
encoding.writeVarString(encoder, this.nodeName)
}
/**
* Integrates this Item into the shared structure.
*
* This method actually applies the change to the Yjs instance. In case of
* Item it connects _left and _right to this Item and calls the
* {@link Item#beforeChange} method.
*
* * Checks for nodeName
* * Sets domFilter
*
* @private
* @param {Y} y The Yjs instance
*/
_integrate (y) {
if (this.nodeName === null) {
throw new Error('nodeName must be defined!')
}
super._integrate(y)
}
toString () {
return this.toDomString()
}
/**
* Returns the string representation of this YXmlElement.
* The attributes are ordered by attribute-name, so you can easily use this
* method to compare YXmlElements
*
* @return {String} The string representation of this type.
*
* @public
*/
toDomString () {
const attrs = this.getAttributes()
const stringBuilder = []
const keys = []
for (let key in attrs) {
keys.push(key)
}
keys.sort()
const keysLen = keys.length
for (let i = 0; i < keysLen; i++) {
const key = keys[i]
stringBuilder.push(key + '="' + attrs[key] + '"')
}
const nodeName = this.nodeName.toLocaleLowerCase()
const attrsString = stringBuilder.length > 0 ? ' ' + stringBuilder.join(' ') : ''
return `<${nodeName}${attrsString}>${super.toDomString()}</${nodeName}>`
}
/**
* Removes an attribute from this YXmlElement.
*
* @param {String} attributeName The attribute name that is to be removed.
*
* @public
*/
removeAttribute (attributeName) {
return YMap.prototype.delete.call(this, attributeName)
}
/**
* Sets or updates an attribute.
*
* @param {String} attributeName The attribute name that is to be set.
* @param {String} attributeValue The attribute value that is to be set.
*
* @public
*/
setAttribute (attributeName, attributeValue) {
return YMap.prototype.set.call(this, attributeName, attributeValue)
}
/**
* Returns an attribute value that belongs to the attribute name.
*
* @param {String} attributeName The attribute name that identifies the
* queried value.
* @param {HistorySnapshot} [snapshot]
* @return {String} The queried attribute value.
*
* @public
*/
getAttribute (attributeName, snapshot) {
return YMap.prototype.get.call(this, attributeName, snapshot)
}
/**
* Returns all attribute name/value pairs in a JSON Object.
*
* @param {HistorySnapshot} [snapshot]
* @return {Object} A JSON Object that describes the attributes.
*
* @public
*/
getAttributes (snapshot) {
const obj = {}
if (snapshot === undefined) {
for (let [key, value] of this._map) {
if (!value._deleted) {
obj[key] = value._content[0]
}
}
} else {
YMap.prototype.keys.call(this, snapshot).forEach(key => {
obj[key] = YMap.prototype.get.call(this, key, snapshot)
})
}
return obj
}
// TODO: outsource the binding property.
/**
* Creates a Dom Element that mirrors this YXmlElement.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object<string, any>} [hooks={}] Optional property to customize how hooks
* are presented in the DOM
* @param {DomBinding} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type.
* @return {Element} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDom (_document = document, hooks = {}, binding) {
const dom = _document.createElement(this.nodeName)
let attrs = this.getAttributes()
for (let key in attrs) {
dom.setAttribute(key, attrs[key])
}
this.forEach(yxml => {
dom.appendChild(yxml.toDom(_document, hooks, binding))
})
if (binding !== undefined) {
binding._createAssociation(dom, this)
}
return dom
}
}

54
src/types/YXmlEvent.js Normal file
View File

@@ -0,0 +1,54 @@
/**
* @module types
*/
import { YEvent } from '../utils/YEvent.js'
import { Type } from '../structs/Type.js' // eslint-disable-line
import { Transaction } from '../utils/Transaction.js' // eslint-disable-line
/**
* An Event that describes changes on a YXml Element or Yxml Fragment
*
* @protected
*/
export class YXmlEvent extends YEvent {
/**
* @param {Type} 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 {Boolean} remote Whether this change was created by a remote peer.
* @param {Transaction} transaction The transaction instance with wich the
* change was created.
*/
constructor (target, subs, remote, transaction) {
super(target)
/**
* The transaction instance for the computed change.
* @type {Transaction}
*/
this._transaction = transaction
/**
* Whether the children changed.
* @type {Boolean}
*/
this.childListChanged = false
/**
* Set of all changed attributes.
* @type {Set}
*/
this.attributesChanged = new Set()
/**
* Whether this change was created by a remote peer.
* @type {Boolean}
*/
this.remote = remote
subs.forEach((sub) => {
if (sub === null) {
this.childListChanged = true
} else {
this.attributesChanged.add(sub)
}
})
}
}

116
src/types/YXmlHook.js Normal file
View File

@@ -0,0 +1,116 @@
/**
* @module types
*/
import { YMap } from './YMap.js'
import * as encoding from 'funlib/encoding.js'
import * as decoding from 'funlib/decoding.js'
import { Y } from '../utils/Y.js' // eslint-disable-line
/**
* You can manage binding to a custom type with YXmlHook.
*
* @public
*/
export class YXmlHook extends YMap {
/**
* @param {String} [hookName] nodeName of the Dom Node.
*/
constructor (hookName) {
super()
this.hookName = null
if (hookName !== undefined) {
this.hookName = hookName
}
}
/**
* Creates an Item with the same effect as this Item (without position effect)
*
* @private
*/
_copy () {
const struct = super._copy()
struct.hookName = this.hookName
return struct
}
/**
* Creates a Dom Element that mirrors this YXmlElement.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object.<string, any>} [hooks] Optional property to customize how hooks
* are presented in the DOM
* @param {DomBinding} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type
* @return {Element} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDom (_document = document, hooks = {}, binding) {
const hook = hooks[this.hookName]
let dom
if (hook !== undefined) {
dom = hook.createDom(this)
} else {
dom = document.createElement(this.hookName)
}
dom.setAttribute('data-yjs-hook', this.hookName)
if (binding !== undefined) {
binding._createAssociation(dom, this)
}
return dom
}
/**
* Read the next Item in a Decoder and fill this Item with the read data.
*
* This is called when data is received from a remote peer.
*
* @param {Y} y The Yjs instance that this Item belongs to.
* @param {decoding.Decoder} decoder The decoder object to read data from.
*
* @private
*/
_fromBinary (y, decoder) {
const missing = super._fromBinary(y, decoder)
this.hookName = decoding.readVarString(decoder)
return missing
}
/**
* Transform the properties of this type to binary and write it to an
* BinaryEncoder.
*
* This is called when this Item is sent to a remote peer.
*
* @param {encoding.Encoder} encoder The encoder to write data to.
*
* @private
*/
_toBinary (encoder) {
super._toBinary(encoder)
encoding.writeVarString(encoder, this.hookName)
}
/**
* Integrate this type into the Yjs instance.
*
* * Save this struct in the os
* * This type is sent to other client
* * Observer functions are fired
*
* @param {Y} y The Yjs instance
*
* @private
*/
_integrate (y) {
if (this.hookName === null) {
throw new Error('hookName must be defined!')
}
super._integrate(y)
}
}

50
src/types/YXmlText.js Normal file
View File

@@ -0,0 +1,50 @@
/**
* @module types
*/
import { YText } from './YText.js'
import { Y } from '../utils/Y.js' // eslint-disable-line
/**
* Represents text in a Dom Element. In the future this type will also handle
* simple formatting information like bold and italic.
*/
export class YXmlText extends YText {
/**
* Creates a Dom Element that mirrors this YXmlText.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object<string, any>} [hooks] Optional property to customize how hooks
* are presented in the DOM
* @param {DomBinding} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type.
* @return {Text} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDom (_document = document, hooks, binding) {
const dom = _document.createTextNode(this.toString())
if (binding !== undefined) {
binding._createAssociation(dom, this)
}
return dom
}
/**
* Mark this Item as deleted.
*
* @param {Y} y The Yjs instance
* @param {boolean} createDelete Whether to propagate a message that this
* Type was deleted.
* @param {boolean} [gcChildren=y._hasUndoManager===false] Whether to garbage
* collect the children of this type.
*
* @private
*/
_delete (y, createDelete, gcChildren) {
super._delete(y, createDelete, gcChildren)
}
}