merge with master

This commit is contained in:
Kevin Jahns 2018-04-27 18:39:34 +02:00
commit 09a94f053e
95 changed files with 5501 additions and 1961 deletions

10
.esdoc.json Normal file
View File

@ -0,0 +1,10 @@
{
"source": "./src",
"destination": "./docs",
"plugins": [{
"name": "esdoc-standard-plugin",
"option": {
"accessor": {"access": ["public"], "autoPrivate": true}
}
}]
}

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
node_modules node_modules
bower_components bower_components
docs
/y.* /y.*
/examples/yjs-dist.js* /examples/yjs-dist.js*

View File

@ -6,13 +6,6 @@ text, richtext, json, or XML. It is fairly easy to get started, as Yjs hides
most of the complexity of concurrent editing. For additional information, demos, most of the complexity of concurrent editing. For additional information, demos,
and tutorials visit [y-js.org](http://y-js.org/). and tutorials visit [y-js.org](http://y-js.org/).
>**If you ever felt like giving back - now is the time! Me and a group of friends have organized a fundraiser to bring heathy food to unprivileged children in Vegas. Good food is often hard to come by. Thus some children dont eat vegetables on a regular basis. We are offering free daily meals with fresh produce and we are going to build a self-sustainable garden at an elementary school to educate children how to live healthy. https://urbanseedfoundation.networkforgood.com/projects/48182-kevin-jahns-s-fundraiser**
>
> Your support on my funding page would mean the world to me :raised_hands:
>
> Also check the description in the link above: If we get to 2500$, I'm going to publish a premium Yjs documentation for the upcoming v13 release! There are also some other goals. The fundraising campaign ends very soon!
### Extensions ### Extensions
Yjs only knows how to resolve conflicts on shared data. You have to choose a .. Yjs only knows how to resolve conflicts on shared data. You have to choose a ..
* *Connector* - a communication protocol that propagates changes to the clients * *Connector* - a communication protocol that propagates changes to the clients

View File

@ -1,12 +1,25 @@
/* global Y, d3 */ /* global Y, d3 */
const hooks = {
'magic-drawing': {
fillType: function (dom, type) {
initDrawingBindings(type, dom)
},
createDom: function (type) {
const dom = document.createElement('magic-drawing')
initDrawingBindings(type, dom)
return dom
}
}
}
window.onload = function () { window.onload = function () {
window.yXmlType.bindToDom(document.body) window.domBinding = new Y.DomBinding(window.yXmlType, document.body, { hooks })
} }
window.addMagicDrawing = function addMagicDrawing () { window.addMagicDrawing = function addMagicDrawing () {
let mt = document.createElement('magic-drawing') let mt = document.createElement('magic-drawing')
mt.dataset.yjsHook = 'magic-drawing' mt.setAttribute('data-yjs-hook', 'magic-drawing')
document.body.append(mt) document.body.append(mt)
} }
@ -17,7 +30,7 @@ var renderPath = d3.svg.line()
function initDrawingBindings (type, dom) { function initDrawingBindings (type, dom) {
dom.contentEditable = 'false' dom.contentEditable = 'false'
dom.dataset.yjsHook = 'magic-drawing' dom.setAttribute('data-yjs-hook', 'magic-drawing')
var drawing = type.get('drawing') var drawing = type.get('drawing')
if (drawing === undefined) { if (drawing === undefined) {
drawing = type.set('drawing', new Y.Array()) drawing = type.set('drawing', new Y.Array())
@ -96,17 +109,6 @@ function initDrawingBindings (type, dom) {
} }
} }
Y.XmlHook.addHook('magic-drawing', {
fillType: function (dom, type) {
initDrawingBindings(type, dom)
},
createDom: function (type) {
const dom = document.createElement('magic-drawing')
initDrawingBindings(type, dom)
return dom
}
})
let y = new Y('html-editor-drawing-hook-example', { let y = new Y('html-editor-drawing-hook-example', {
connector: { connector: {
name: 'websockets-client', name: 'websockets-client',

View File

@ -1,7 +1,7 @@
/* global Y */ /* global Y */
window.onload = function () { window.onload = function () {
window.yXmlType.bindToDom(document.body) window.domBinding = new Y.DomBinding(window.yXmlType, document.body)
} }
let y = new Y('htmleditor', { let y = new Y('htmleditor', {

View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<!-- Main quill library -->
<script src="../../node_modules/quill/dist/quill.min.js"></script>
<link href="../../node_modules/quill/dist/quill.snow.css" rel="stylesheet">
<!-- Quill cursors module -->
<script src="../../node_modules/quill-cursors/dist/quill-cursors.min.js"></script>
<link href="../../node_modules/quill-cursors/dist/quill-cursors.css" rel="stylesheet">
<!-- Yjs Library and connector -->
<script src="../../y.js"></script>
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
</head>
<body>
<div id="quill-container">
<div id="quill">
</div>
</div>
<script src="./index.js"></script>
</body>
</html>

View File

@ -0,0 +1,78 @@
/* global Y, Quill, QuillCursors */
Quill.register('modules/cursors', QuillCursors)
let y = new Y('quill-0', {
connector: {
name: 'websockets-client',
url: 'http://127.0.0.1:1234'
}
})
let users = y.define('users', Y.Array)
let myUserInfo = new Y.Map()
myUserInfo.set('name', 'dada')
myUserInfo.set('color', 'red')
users.push([myUserInfo])
let quill = new Quill('#quill-container', {
modules: {
toolbar: [
[{ header: [1, 2, false] }],
['bold', 'italic', 'underline'],
['image', 'code-block'],
[{ color: [] }, { background: [] }], // Snow theme fills in values
[{ script: 'sub' }, { script: 'super' }],
['link', 'image'],
['link', 'code-block'],
[{ list: 'ordered' }, { list: 'bullet' }]
],
cursors: {
hideDelay: 500
}
},
placeholder: 'Compose an epic...',
theme: 'snow' // or 'bubble'
})
let cursors = quill.getModule('cursors')
function drawCursors () {
cursors.clearCursors()
users.map((user, userId) => {
if (user !== myUserInfo) {
let relativeRange = user.get('range')
let lastUpdated = new Date(user.get('last updated'))
if (lastUpdated != null && new Date() - lastUpdated < 20000 && relativeRange != null) {
let start = Y.utils.fromRelativePosition(y, relativeRange.start).offset
let end = Y.utils.fromRelativePosition(y, relativeRange.end).offset
let range = { index: start, length: end - start }
cursors.setCursor(userId + '', range, user.get('name'), user.get('color'))
}
}
})
}
users.observeDeep(drawCursors)
drawCursors()
quill.on('selection-change', function (range) {
if (range != null) {
myUserInfo.set('range', {
start: Y.utils.getRelativePosition(yText, range.index),
end: Y.utils.getRelativePosition(yText, range.index + range.length)
})
} else {
myUserInfo.delete('range')
}
myUserInfo.set('last updated', new Date().toString())
})
let yText = y.define('quill', Y.Text)
let quillBinding = new Y.QuillBinding(yText, quill)
window.quillBinding = quillBinding
window.yText = yText
window.y = y
window.quill = quill
window.users = users
window.cursors = cursors

View File

@ -1,32 +1,18 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<!-- quill does not include dist files! We are using the hosted version instead --> <!-- Main Quill library -->
<!--link rel="stylesheet" href="../bower_components/quill/dist/quill.snow.css" /--> <script src="../../node_modules/quill/dist/quill.min.js"></script>
<link href="https://cdn.quilljs.com/1.0.4/quill.snow.css" rel="stylesheet"> <link href="../../node_modules/quill/dist/quill.snow.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.5.1/katex.min.css" rel="stylesheet"> <!-- Yjs Library and connector -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.2.0/styles/monokai-sublime.min.css" rel="stylesheet"> <script src="../../y.js"></script>
<style> <script src='../../../y-websockets-client/y-websockets-client.js'></script>
#quill-container {
border: 1px solid gray;
box-shadow: 0px 0px 10px gray;
}
</style>
</head> </head>
<body> <body>
<div id="quill-container"> <div id="quill-container">
<div id="quill"> <div id="quill">
</div> </div>
</div> </div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.5.1/katex.min.js" type="text/javascript"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.2.0/highlight.min.js" type="text/javascript"></script>
<script src="https://cdn.quilljs.com/1.0.4/quill.js"></script>
<!-- quill does not include dist files! We are using the hosted version instead (see above)
<script src="../bower_components/quill/dist/quill.js"></script>
-->
<script src="../../y.js"></script>
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
<script src="./index.js"></script> <script src="./index.js"></script>
</body> </body>
</html> </html>

View File

@ -1,40 +1,33 @@
/* global Y, Quill */ /* global Y, Quill */
// initialize a shared object. This function call returns a promise! let y = new Y('quill-cursors-0', {
Y({
db: {
name: 'memory'
},
connector: { connector: {
name: 'websockets-client', name: 'websockets-client',
room: 'richtext-example-quill-1.0-test', url: 'http://127.0.0.1:1234'
url: 'http://localhost:1234'
},
sourceDir: '/bower_components',
share: {
richtext: 'Richtext' // y.share.richtext is of type Y.Richtext
} }
}).then(function (y) { })
window.yQuill = y
// create quill element let quill = new Quill('#quill-container', {
window.quill = new Quill('#quill', {
modules: { modules: {
formula: true,
syntax: true,
toolbar: [ toolbar: [
[{ size: ['small', false, 'large', 'huge'] }], [{ header: [1, 2, false] }],
['bold', 'italic', 'underline'], ['bold', 'italic', 'underline'],
['image', 'code-block'],
[{ color: [] }, { background: [] }], // Snow theme fills in values [{ color: [] }, { background: [] }], // Snow theme fills in values
[{ script: 'sub' }, { script: 'super' }], [{ script: 'sub' }, { script: 'super' }],
['link', 'image'], ['link', 'image'],
['link', 'code-block'], ['link', 'code-block'],
[{ list: 'ordered' }] [{ list: 'ordered' }, { list: 'bullet' }]
] ]
}, },
theme: 'snow' placeholder: 'Compose an epic...',
}) theme: 'snow' // or 'bubble'
// bind quill to richtext type
y.share.richtext.bind(window.quill)
}) })
let yText = y.define('quill', Y.Text)
let quillBinding = new Y.QuillBinding(yText, quill)
window.quillBinding = quillBinding
window.yText = yText
window.y = y
window.quill = quill

View File

@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
</head> </head>
<!-- jquery is not required for y-xml. It is just here for convenience, and to test batch operations. --> <!-- jquery is not required for YXml. It is just here for convenience, and to test batch operations. -->
<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script> <script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
<script src="../../y.js"></script> <script src="../../y.js"></script>
<script src='../../../y-websockets-client/y-websockets-client.js'></script> <script src='../../../y-websockets-client/y-websockets-client.js'></script>
@ -24,14 +24,16 @@
</div> </div>
<script> <script>
var commands = document.querySelectorAll(".command"); /* global $ */
Array.prototype.forEach.call(document.querySelectorAll('.command'), function (command) { var commands = document.querySelectorAll('.command')
Array.prototype.forEach.call(commands, function (command) {
var execute = function () { var execute = function () {
eval(command.querySelector("input").value); // eslint-disable-next-line no-eval
eval(command.querySelector('input').value)
} }
command.querySelector("button").onclick = execute command.querySelector('button').onclick = execute
$(command.querySelector("input")).keyup(function (e) { $(command.querySelector('input')).keyup(function (e) {
if (e.keyCode == 13) { if (e.keyCode === 13) {
execute() execute()
} }
}) })

View File

@ -9,5 +9,5 @@ let y = new Y('xml-example', {
window.yXml = y window.yXml = y
// bind xml type to a dom, and put it in body // bind xml type to a dom, and put it in body
window.sharedDom = y.define('xml', Y.XmlElement).getDom() window.sharedDom = y.define('xml', Y.XmlElement).toDom()
document.body.appendChild(window.sharedDom) document.body.appendChild(window.sharedDom)

1255
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,8 @@
"test": "npm run lint", "test": "npm run lint",
"debug": "concurrently 'rollup -wc rollup.test.js' 'cutest-serve y.test.js -o'", "debug": "concurrently 'rollup -wc rollup.test.js' 'cutest-serve y.test.js -o'",
"lint": "standard", "lint": "standard",
"docs": "esdoc",
"serve-docs": "npm run docs && serve ./docs/",
"dist": "rollup -c rollup.browser.js; rollup -c rollup.node.js", "dist": "rollup -c rollup.browser.js; rollup -c rollup.node.js",
"watch": "concurrently 'rollup -wc rollup.browser.js' 'rollup -wc rollup.node.js'", "watch": "concurrently 'rollup -wc rollup.browser.js' 'rollup -wc rollup.node.js'",
"postversion": "npm run dist", "postversion": "npm run dist",
@ -16,7 +18,9 @@
}, },
"files": [ "files": [
"y.*", "y.*",
"src/*" "src/*",
".esdoc.json",
"docs/*"
], ],
"standard": { "standard": {
"ignore": [ "ignore": [
@ -53,6 +57,10 @@
"chance": "^1.0.9", "chance": "^1.0.9",
"concurrently": "^3.4.0", "concurrently": "^3.4.0",
"cutest": "^0.1.9", "cutest": "^0.1.9",
"esdoc": "^1.0.4",
"esdoc-standard-plugin": "^1.0.0",
"quill": "^1.3.5",
"quill-cursors": "^1.0.2",
"rollup-plugin-babel": "^2.7.1", "rollup-plugin-babel": "^2.7.1",
"rollup-plugin-commonjs": "^8.0.2", "rollup-plugin-commonjs": "^8.0.2",
"rollup-plugin-inject": "^2.0.0", "rollup-plugin-inject": "^2.0.0",

View File

@ -5,7 +5,7 @@ import commonjs from 'rollup-plugin-commonjs'
var pkg = require('./package.json') var pkg = require('./package.json')
export default { export default {
input: 'src/Y.js', input: 'src/Y.dist.js',
name: 'Y', name: 'Y',
sourcemap: true, sourcemap: true,
output: { output: {

View File

@ -3,7 +3,7 @@ import commonjs from 'rollup-plugin-commonjs'
var pkg = require('./package.json') var pkg = require('./package.json')
export default { export default {
input: 'src/y-dist.cjs.js', input: 'src/Y.dist.js',
nameame: 'Y', nameame: 'Y',
sourcemap: true, sourcemap: true,
output: { output: {

View File

@ -1,83 +0,0 @@
import { RootFakeUserID } from '../Util/RootID.js'
const bits7 = 0b1111111
const bits8 = 0b11111111
export default class BinaryEncoder {
constructor () {
// TODO: implement chained Uint8Array buffers instead of Array buffer
this.data = []
}
get length () {
return this.data.length
}
get pos () {
return this.data.length
}
createBuffer () {
return Uint8Array.from(this.data).buffer
}
writeUint8 (num) {
this.data.push(num & bits8)
}
setUint8 (pos, num) {
this.data[pos] = num & bits8
}
writeUint16 (num) {
this.data.push(num & bits8, (num >>> 8) & bits8)
}
setUint16 (pos, num) {
this.data[pos] = num & bits8
this.data[pos + 1] = (num >>> 8) & bits8
}
writeUint32 (num) {
for (let i = 0; i < 4; i++) {
this.data.push(num & bits8)
num >>>= 8
}
}
setUint32 (pos, num) {
for (let i = 0; i < 4; i++) {
this.data[pos + i] = num & bits8
num >>>= 8
}
}
writeVarUint (num) {
while (num >= 0b10000000) {
this.data.push(0b10000000 | (bits7 & num))
num >>>= 7
}
this.data.push(bits7 & num)
}
writeVarString (str) {
let encodedString = unescape(encodeURIComponent(str))
let bytes = encodedString.split('').map(c => c.codePointAt())
let len = bytes.length
this.writeVarUint(len)
for (let i = 0; i < len; i++) {
this.data.push(bytes[i])
}
}
writeID (id) {
const user = id.user
this.writeVarUint(user)
if (user !== RootFakeUserID) {
this.writeVarUint(id.clock)
} else {
this.writeVarString(id.name)
this.writeVarUint(id.type)
}
}
}

View File

@ -1,14 +0,0 @@
import { createMutualExclude } from '../Util/mutualExclude.js'
export default class Binding {
constructor (type, target) {
this.type = type
this.target = target
this._mutualExclude = createMutualExclude()
}
destroy () {
this.type = null
this.target = null
}
}

47
src/Bindings/Binding.js Normal file
View File

@ -0,0 +1,47 @@
import { createMutualExclude } from '../Util/mutualExclude.js'
/**
* Abstract class for bindings.
*
* A binding handles data binding from a Yjs type to a data object. For example,
* you can bind a Quill editor instance to a YText instance with the `QuillBinding` class.
*
* It is expected that a concrete implementation accepts two parameters
* (type and binding target).
*
* @example
* const quill = new Quill(document.createElement('div'))
* const type = y.define('quill', Y.Text)
* const binding = new Y.QuillBinding(quill, type)
*
*/
export default class Binding {
/**
* @param {YType} type Yjs type.
* @param {any} target Binding Target.
*/
constructor (type, target) {
/**
* The Yjs type that is bound to `target`
* @type {YType}
*/
this.type = type
/**
* The target that `type` is bound to.
* @type {*}
*/
this.target = target
/**
* @private
*/
this._mutualExclude = createMutualExclude()
}
/**
* Remove all data observers (both from the type and the target).
*/
destroy () {
this.type = null
this.target = null
}
}

View File

@ -0,0 +1,151 @@
/* global MutationObserver */
import Binding from '../Binding.js'
import { createAssociation, removeAssociation } from './util.js'
import { beforeTransactionSelectionFixer, afterTransactionSelectionFixer } from './selection.js'
import { defaultFilter, applyFilterOnType } from './filter.js'
import typeObserver from './typeObserver.js'
import domObserver from './domObserver.js'
/**
* A binding that binds the children of a YXmlFragment to a DOM element.
*
* This binding is automatically destroyed when its parent is deleted.
*
* @example
* const div = document.createElement('div')
* const type = y.define('xml', Y.XmlFragment)
* const binding = new Y.QuillBinding(type, div)
*
*/
export default class DomBinding extends Binding {
/**
* @param {YXmlFragment} type The bind source. This is the ultimate source of
* truth.
* @param {Element} target The bind target. Mirrors the target.
* @param {Object} [opts] Optional configurations
* @param {FilterFunction} [opts.filter=defaultFilter] The filter function to use.
*/
constructor (type, target, opts = {}) {
// Binding handles textType as this.type and domTextarea as this.target
super(type, target)
this.opts = opts
opts.document = opts.document || document
opts.hooks = opts.hooks || {}
/**
* Maps each DOM element to the type that it is associated with.
* @type {Map}
*/
this.domToType = new Map()
/**
* Maps each YXml type to the DOM element that it is associated with.
* @type {Map}
*/
this.typeToDom = new Map()
/**
* Defines which DOM attributes and elements to filter out.
* Also filters remote changes.
* @type {FilterFunction}
*/
this.filter = opts.filter || defaultFilter
// set initial value
target.innerHTML = ''
type.forEach(child => {
target.insertBefore(child.toDom(opts.document, opts.hooks, this), null)
})
this._typeObserver = typeObserver.bind(this)
this._domObserver = (mutations) => {
domObserver.call(this, mutations, opts.document)
}
type.observeDeep(this._typeObserver)
this._mutationObserver = new MutationObserver(this._domObserver)
this._mutationObserver.observe(target, {
childList: true,
attributes: true,
characterData: true,
subtree: true
})
const y = type._y
// Force flush dom changes before Type changes are applied (they might
// modify the dom)
this._beforeTransactionHandler = (y, transaction, remote) => {
this._domObserver(this._mutationObserver.takeRecords())
beforeTransactionSelectionFixer(y, this, transaction, remote)
}
y.on('beforeTransaction', this._beforeTransactionHandler)
this._afterTransactionHandler = (y, transaction, remote) => {
afterTransactionSelectionFixer(y, this, transaction, remote)
// remove associations
// TODO: this could be done more efficiently
// e.g. Always delete using the following approach, or removeAssociation
// in dom/type-observer..
transaction.deletedStructs.forEach(type => {
const dom = this.typeToDom.get(type)
if (dom !== undefined) {
removeAssociation(this, dom, type)
}
})
}
y.on('afterTransaction', this._afterTransactionHandler)
// Before calling observers, apply dom filter to all changed and new types.
this._beforeObserverCallsHandler = (y, transaction) => {
// Apply dom filter to new and changed types
transaction.changedTypes.forEach((subs, type) => {
// Only check attributes. New types are filtered below.
if ((subs.size > 1 || (subs.size === 1 && subs.has(null) === false))) {
applyFilterOnType(y, this, type)
}
})
transaction.newTypes.forEach(type => {
applyFilterOnType(y, this, type)
})
}
y.on('beforeObserverCalls', this._beforeObserverCallsHandler)
createAssociation(this, target, type)
}
/**
* Enables the smart scrolling functionality for a Dom Binding.
* This is useful when YXml is bound to a shared editor. When activated,
* the viewport will be changed to accommodate remote changes.
*
* @param {Element} scrollElement The node that is
*/
enableSmartScrolling (scrollElement) {
// @TODO: implement smart scrolling
}
/**
* NOTE: currently does not apply filter to existing elements!
* @param {FilterFunction} filter The filter function to use from now on.
*/
setFilter (filter) {
this.filter = filter
// TODO: apply filter to all elements
}
/**
* Remove all properties that are handled by this class.
*/
destroy () {
this.domToType = null
this.typeToDom = null
this.type.unobserve(this._typeObserver)
this._mutationObserver.disconnect()
const y = this.type._y
y.off('beforeTransaction', this._beforeTransactionHandler)
y.off('beforeObserverCalls', this._beforeObserverCallsHandler)
y.off('afterObserverCalls', this._afterObserverCallsHandler)
y.off('afterTransaction', this._afterTransactionHandler)
super.destroy()
}
}
/**
* A filter defines which elements and attributes to share.
* Return null if the node should be filtered. Otherwise return the Map of
* accepted attributes.
*
* @typedef {function(nodeName: String, attrs: Map): Map|null} FilterFunction
*/

View File

@ -0,0 +1,136 @@
import YXmlHook from '../../Types/YXml/YXmlHook.js'
import {
iterateUntilUndeleted,
removeAssociation,
insertNodeHelper } from './util.js'
import diff from '../../Util/simpleDiff.js'
import YXmlFragment from '../../Types/YXml/YXmlFragment.js'
/**
* 1. Check if any of the nodes was deleted
* 2. Iterate over the children.
* 2.1 If a node exists that is not yet bound to a type, insert a new node
* 2.2 If _contents.length < dom.childNodes.length, fill the
* rest of _content with childNodes
* 2.3 If a node was moved, delete it and
* recreate a new yxml element that is bound to that node.
* You can detect that a node was moved because expectedId
* !== actualId in the list
* @private
*/
function applyChangesFromDom (binding, dom, yxml, _document) {
if (yxml == null || yxml === false || yxml.constructor === YXmlHook) {
return
}
const y = yxml._y
const knownChildren = new Set()
for (let i = dom.childNodes.length - 1; i >= 0; i--) {
const type = binding.domToType.get(dom.childNodes[i])
if (type !== undefined && type !== false) {
knownChildren.add(type)
}
}
// 1. Check if any of the nodes was deleted
yxml.forEach(function (childType) {
if (knownChildren.has(childType) === false) {
childType._delete(y)
removeAssociation(binding, binding.typeToDom.get(childType), childType)
}
})
// 2. iterate
const childNodes = dom.childNodes
const len = childNodes.length
let prevExpectedType = null
let expectedType = iterateUntilUndeleted(yxml._start)
for (let domCnt = 0; domCnt < len; domCnt++) {
const childNode = childNodes[domCnt]
const childType = binding.domToType.get(childNode)
if (childType !== undefined) {
if (childType === false) {
// should be ignored or is going to be deleted
continue
}
if (expectedType !== null) {
if (expectedType !== childType) {
// 2.3 Not expected node
if (childType._parent !== yxml) {
// child was moved from another parent
// childType is going to be deleted by its previous parent
removeAssociation(binding, childNode, childType)
} else {
// child was moved to a different position.
childType._delete(y)
removeAssociation(binding, childNode, childType)
}
prevExpectedType = insertNodeHelper(yxml, prevExpectedType, childNode, _document, binding)
} else {
// Found expected node. Continue.
prevExpectedType = expectedType
expectedType = iterateUntilUndeleted(expectedType._right)
}
} else {
// 2.2 Fill _content with child nodes
prevExpectedType = insertNodeHelper(yxml, prevExpectedType, childNode, _document, binding)
}
} else {
// 2.1 A new node was found
prevExpectedType = insertNodeHelper(yxml, prevExpectedType, childNode, _document, binding)
}
}
}
/**
* @private
*/
export default function domObserver (mutations, _document) {
this._mutualExclude(() => {
this.type._y.transact(() => {
let diffChildren = new Set()
mutations.forEach(mutation => {
const dom = mutation.target
const yxml = this.domToType.get(dom)
if (yxml === false || yxml === undefined || yxml.constructor === YXmlHook) {
// dom element is filtered
return
}
switch (mutation.type) {
case 'characterData':
var change = diff(yxml.toString(), dom.nodeValue)
yxml.delete(change.pos, change.remove)
yxml.insert(change.pos, change.insert)
break
case 'attributes':
if (yxml.constructor === YXmlFragment) {
break
}
let name = mutation.attributeName
let val = dom.getAttribute(name)
// check if filter accepts attribute
let attributes = new Map()
attributes.set(name, val)
if (yxml.constructor !== YXmlFragment && this.filter(dom.nodeName, attributes).size > 0) {
if (yxml.getAttribute(name) !== val) {
if (val == null) {
yxml.removeAttribute(name)
} else {
yxml.setAttribute(name, val)
}
}
}
break
case 'childList':
diffChildren.add(mutation.target)
break
}
})
for (let dom of diffChildren) {
if (dom.yOnChildrenChanged !== undefined) {
dom.yOnChildrenChanged()
}
const yxml = this.domToType.get(dom)
applyChangesFromDom(this, dom, yxml, _document)
}
})
})
}

View File

@ -0,0 +1,59 @@
import { YXmlText, YXmlElement, YXmlHook } from '../../Types/YXml/YXml.js'
import { createAssociation, domsToTypes } from './util.js'
import { filterDomAttributes, defaultFilter } from './filter.js'
/**
* Creates a Yjs type (YXml) based on the contents of a DOM Element.
*
* @param {Element|TextNode} element The DOM Element
* @param {?Document} _document Optional. Provide the global document object
* @param {Hooks} [hooks = {}] Optional. Set of Yjs Hooks
* @param {Filter} [filter=defaultFilter] Optional. Dom element filter
* @param {?DomBinding} binding Warning: This property is for internal use only!
* @return {YXmlElement | YXmlText}
*/
export default function domToType (element, _document = document, hooks = {}, filter = defaultFilter, binding) {
let type
switch (element.nodeType) {
case _document.ELEMENT_NODE:
let hookName = null
let hook
// configure `hookName !== undefined` if element is a hook.
if (element.hasAttribute('data-yjs-hook')) {
hookName = element.getAttribute('data-yjs-hook')
hook = hooks[hookName]
if (hook === undefined) {
console.error(`Unknown hook "${hookName}". Deleting yjsHook dataset property.`)
delete element.removeAttribute('data-yjs-hook')
hookName = null
}
}
if (hookName === null) {
// Not a hook
const attrs = filterDomAttributes(element, filter)
if (attrs === null) {
type = false
} else {
type = new YXmlElement(element.nodeName)
attrs.forEach((val, key) => {
type.setAttribute(key, val)
})
type.insert(0, domsToTypes(element.childNodes, document, hooks, filter, binding))
}
} else {
// Is a hook
type = new YXmlHook(hookName)
hook.fillType(element, type)
}
break
case _document.TEXT_NODE:
type = new YXmlText()
type.insert(0, element.nodeValue)
break
default:
throw new Error('Can\'t transform this node type to a YXml type!')
}
createAssociation(binding, element, type)
return type
}

View File

@ -0,0 +1,60 @@
import isParentOf from '../../Util/isParentOf.js'
/**
* Default filter method (does nothing).
*
* @param {String} nodeName The nodeName of the element
* @param {Map} attrs Map of key-value pairs that are attributes of the node.
* @return {Map | null} The allowed attributes or null, if the element should be
* filtered.
*/
export function defaultFilter (nodeName, attrs) {
// TODO: implement basic filter that filters out dangerous properties!
return attrs
}
/**
*
*/
export function filterDomAttributes (dom, filter) {
const attrs = new Map()
for (let i = dom.attributes.length - 1; i >= 0; i--) {
const attr = dom.attributes[i]
attrs.set(attr.name, attr.value)
}
return filter(dom.nodeName, attrs)
}
/**
* Applies a filter on a type.
*
* @param {Y} y The Yjs instance.
* @param {DomBinding} binding The DOM binding instance that has the dom filter.
* @param {YXmlElement | YXmlFragment } type The type to apply the filter to.
*
* @private
*/
export function applyFilterOnType (y, binding, type) {
if (isParentOf(binding.type, type)) {
const nodeName = type.nodeName
let attributes = new Map()
if (type.getAttributes !== undefined) {
let attrs = type.getAttributes()
for (let key in attrs) {
attributes.set(key, attrs[key])
}
}
const filteredAttributes = binding.filter(nodeName, new Map(attributes))
if (filteredAttributes === null) {
type._delete(y)
} else {
// iterate original attributes
attributes.forEach((value, key) => {
// delete all attributes that are not in filteredAttributes
if (filteredAttributes.has(key) === false) {
type.removeAttribute(key)
}
})
}
}
}

View File

@ -5,32 +5,38 @@ import { getRelativePosition, fromRelativePosition } from '../../Util/relativePo
let browserSelection = null let browserSelection = null
let relativeSelection = null let relativeSelection = null
/**
* @private
*/
export let beforeTransactionSelectionFixer export let beforeTransactionSelectionFixer
if (typeof getSelection !== 'undefined') { if (typeof getSelection !== 'undefined') {
beforeTransactionSelectionFixer = function _beforeTransactionSelectionFixer (y, transaction, remote) { beforeTransactionSelectionFixer = function _beforeTransactionSelectionFixer (y, domBinding, transaction, remote) {
if (!remote) { if (!remote) {
return return
} }
relativeSelection = { from: null, to: null, fromY: null, toY: null } relativeSelection = { from: null, to: null, fromY: null, toY: null }
browserSelection = getSelection() browserSelection = getSelection()
const anchorNode = browserSelection.anchorNode const anchorNode = browserSelection.anchorNode
if (anchorNode !== null && anchorNode._yxml != null) { const anchorNodeType = domBinding.domToType.get(anchorNode)
const yxml = anchorNode._yxml if (anchorNode !== null && anchorNodeType !== undefined) {
relativeSelection.from = getRelativePosition(yxml, browserSelection.anchorOffset) relativeSelection.from = getRelativePosition(anchorNodeType, browserSelection.anchorOffset)
relativeSelection.fromY = yxml._y relativeSelection.fromY = anchorNodeType._y
} }
const focusNode = browserSelection.focusNode const focusNode = browserSelection.focusNode
if (focusNode !== null && focusNode._yxml != null) { const focusNodeType = domBinding.domToType.get(focusNode)
const yxml = focusNode._yxml if (focusNode !== null && focusNodeType !== undefined) {
relativeSelection.to = getRelativePosition(yxml, browserSelection.focusOffset) relativeSelection.to = getRelativePosition(focusNodeType, browserSelection.focusOffset)
relativeSelection.toY = yxml._y relativeSelection.toY = focusNodeType._y
} }
} }
} else { } else {
beforeTransactionSelectionFixer = function _fakeBeforeTransactionSelectionFixer () {} beforeTransactionSelectionFixer = function _fakeBeforeTransactionSelectionFixer () {}
} }
export function afterTransactionSelectionFixer (y, transaction, remote) { /**
* @private
*/
export function afterTransactionSelectionFixer (y, domBinding, transaction, remote) {
if (relativeSelection === null || !remote) { if (relativeSelection === null || !remote) {
return return
} }
@ -46,7 +52,7 @@ export function afterTransactionSelectionFixer (y, transaction, remote) {
if (from !== null) { if (from !== null) {
let sel = fromRelativePosition(fromY, from) let sel = fromRelativePosition(fromY, from)
if (sel !== null) { if (sel !== null) {
let node = sel.type.getDom() let node = domBinding.typeToDom.get(sel.type)
let offset = sel.offset let offset = sel.offset
if (node !== anchorNode || offset !== anchorOffset) { if (node !== anchorNode || offset !== anchorOffset) {
anchorNode = node anchorNode = node
@ -58,7 +64,7 @@ export function afterTransactionSelectionFixer (y, transaction, remote) {
if (to !== null) { if (to !== null) {
let sel = fromRelativePosition(toY, to) let sel = fromRelativePosition(toY, to)
if (sel !== null) { if (sel !== null) {
let node = sel.type.getDom() let node = domBinding.typeToDom.get(sel.type)
let offset = sel.offset let offset = sel.offset
if (node !== focusNode || offset !== focusOffset) { if (node !== focusNode || offset !== focusOffset) {
focusNode = node focusNode = node

View File

@ -0,0 +1,63 @@
import YXmlText from '../../Types/YXml/YXmlText.js'
import YXmlHook from '../../Types/YXml/YXmlHook.js'
import { removeDomChildrenUntilElementFound } from './util.js'
/**
* @private
*/
export default function typeObserver (events) {
this._mutualExclude(() => {
events.forEach(event => {
const yxml = event.target
const dom = this.typeToDom.get(yxml)
if (dom !== undefined && dom !== false) {
if (yxml.constructor === YXmlText) {
dom.nodeValue = yxml.toString()
// TODO: use hasOwnProperty instead of === undefined check
} else if (event.attributesChanged !== undefined) {
// update attributes
event.attributesChanged.forEach(attributeName => {
const value = yxml.getAttribute(attributeName)
if (value === undefined) {
dom.removeAttribute(attributeName)
} else {
dom.setAttribute(attributeName, value)
}
})
/*
* TODO: instead of hard-checking the types, it would be best to
* specify the type's features. E.g.
* - _yxmlHasAttributes
* - _yxmlHasChildren
* Furthermore, the features shouldn't be encoded in the types,
* only in the attributes (above)
*/
if (event.childListChanged && yxml.constructor !== YXmlHook) {
let currentChild = dom.firstChild
yxml.forEach(childType => {
const childNode = this.typeToDom.get(childType)
switch (childNode) {
case undefined:
// Does not exist. Create it.
const node = childType.toDom(this.opts.document, this.opts.hooks, this)
dom.insertBefore(node, currentChild)
break
case false:
// nop
break
default:
// Is already attached to the dom.
// Find it and remove all dom nodes in-between.
removeDomChildrenUntilElementFound(dom, currentChild, childNode)
currentChild = childNode.nextSibling
break
}
})
removeDomChildrenUntilElementFound(dom, currentChild, null)
}
}
}
})
})
}

View File

@ -0,0 +1,124 @@
import domToType from './domToType.js'
/**
* Iterates items until an undeleted item is found.
*
* @private
*/
export function iterateUntilUndeleted (item) {
while (item !== null && item._deleted) {
item = item._right
}
return item
}
/**
* Removes an association (the information that a DOM element belongs to a
* type).
*
* @param {DomBinding} domBinding The binding object
* @param {Element} dom The dom that is to be associated with type
* @param {YXmlElement|YXmlHook} type The type that is to be associated with dom
*
*/
export function removeAssociation (domBinding, dom, type) {
domBinding.domToType.delete(dom)
domBinding.typeToDom.delete(type)
}
/**
* Creates an association (the information that a DOM element belongs to a
* type).
*
* @param {DomBinding} domBinding The binding object
* @param {Element} dom The dom that is to be associated with type
* @param {YXmlElement|YXmlHook} type The type that is to be associated with dom
*
*/
export function createAssociation (domBinding, dom, type) {
if (domBinding !== undefined) {
domBinding.domToType.set(dom, type)
domBinding.typeToDom.set(type, dom)
}
}
/**
* If oldDom is associated with a type, associate newDom with the type and
* forget about oldDom. If oldDom is not associated with any type, nothing happens.
*
* @param {DomBinding} domBinding The binding object
* @param {Element} oldDom The existing dom
* @param {Element} newDom The new dom object
*/
export function switchAssociation (domBinding, oldDom, newDom) {
if (domBinding !== undefined) {
const type = domBinding.domToType.get(oldDom)
if (type !== undefined) {
removeAssociation(domBinding, oldDom, type)
createAssociation(domBinding, newDom, type)
}
}
}
/**
* Insert Dom Elements after one of the children of this YXmlFragment.
* The Dom elements will be bound to a new YXmlElement and inserted at the
* specified position.
*
* @param {YXmlElement} type The type in which to insert DOM elements.
* @param {YXmlElement|null} prev The reference node. New YxmlElements are
* inserted after this node. Set null to insert at
* the beginning.
* @param {Array<Element>} doms The Dom elements to insert.
* @param {?Document} _document Optional. Provide the global document object.
* @param {DomBinding} binding The dom binding
* @return {Array<YXmlElement>} The YxmlElements that are inserted.
*
* @private
*/
export function insertDomElementsAfter (type, prev, doms, _document, binding) {
const types = domsToTypes(doms, _document, binding.opts.hooks, binding.filter, binding)
return type.insertAfter(prev, types)
}
export function domsToTypes (doms, _document, hooks, filter, binding) {
const types = []
for (let dom of doms) {
const t = domToType(dom, _document, hooks, filter, binding)
if (t !== false) {
types.push(t)
}
}
return types
}
/**
* @private
*/
export function insertNodeHelper (yxml, prevExpectedNode, child, _document, binding) {
let insertedNodes = insertDomElementsAfter(yxml, prevExpectedNode, [child], _document, binding)
if (insertedNodes.length > 0) {
return insertedNodes[0]
} else {
return prevExpectedNode
}
}
/**
* Remove children until `elem` is found.
*
* @param {Element} parent The parent of `elem` and `currentChild`.
* @param {Element} currentChild Start removing elements with `currentChild`. If
* `currentChild` is `elem` it won't be removed.
* @param {Element|null} elem The elemnt to look for.
*
* @private
*/
export function removeDomChildrenUntilElementFound (parent, currentChild, elem) {
while (currentChild !== elem) {
const del = currentChild
currentChild = currentChild.nextSibling
parent.removeChild(del)
}
}

View File

@ -0,0 +1,53 @@
import Binding from '../Binding.js'
function typeObserver (event) {
const quill = this.target
// Force flush Quill changes.
quill.update('yjs')
this._mutualExclude(function () {
// Apply computed delta.
quill.updateContents(event.delta, 'yjs')
// Force flush Quill changes. Ignore applied changes.
quill.update('yjs')
})
}
function quillObserver (delta) {
this._mutualExclude(() => {
this.type.applyDelta(delta.ops)
})
}
/**
* A Binding that binds a YText type to a Quill editor.
*
* @example
* const quill = new Quill(document.createElement('div'))
* const type = y.define('quill', Y.Text)
* const binding = new Y.QuillBinding(quill, type)
* // Now modifications on the DOM will be reflected in the Type, and the other
* // way around!
*/
export default class QuillBinding extends Binding {
/**
* @param {YText} textType
* @param {Quill} quill
*/
constructor (textType, quill) {
// Binding handles textType as this.type and quill as this.target.
super(textType, quill)
// Set initial value.
quill.setContents(textType.toDelta(), 'yjs')
// Observers are handled by this class.
this._typeObserver = typeObserver.bind(this)
this._quillObserver = quillObserver.bind(this)
textType.observe(this._typeObserver)
quill.on('text-change', this._quillObserver)
}
destroy () {
// Remove everything that is handled by this class.
this.type.unobserve(this._typeObserver)
this.target.off('text-change', this._quillObserver)
super.destroy()
}
}

View File

@ -1,7 +1,7 @@
import Binding from './Binding.js' import Binding from '../Binding.js'
import simpleDiff from '../Util/simpleDiff.js' import simpleDiff from '../../Util/simpleDiff.js'
import { getRelativePosition, fromRelativePosition } from '../Util/relativePosition.js' import { getRelativePosition, fromRelativePosition } from '../../Util/relativePosition.js'
function typeObserver () { function typeObserver () {
this._mutualExclude(() => { this._mutualExclude(() => {
@ -24,6 +24,17 @@ function domObserver () {
}) })
} }
/**
* A binding that binds a YText to a dom textarea.
*
* This binding is automatically destroyed when its parent is deleted.
*
* @example
* const textare = document.createElement('textarea')
* const type = y.define('textarea', Y.Text)
* const binding = new Y.QuillBinding(type, textarea)
*
*/
export default class TextareaBinding extends Binding { export default class TextareaBinding extends Binding {
constructor (textType, domTextarea) { constructor (textType, domTextarea) {
// Binding handles textType as this.type and domTextarea as this.target // Binding handles textType as this.type and domTextarea as this.target

View File

@ -1,5 +1,5 @@
import BinaryEncoder from './Binary/Encoder.js' import BinaryEncoder from './Util/Binary/Encoder.js'
import BinaryDecoder from './Binary/Decoder.js' import BinaryDecoder from './Util/Binary/Decoder.js'
import { sendSyncStep1, readSyncStep1 } from './MessageHandler/syncStep1.js' import { sendSyncStep1, readSyncStep1 } from './MessageHandler/syncStep1.js'
import { readSyncStep2 } from './MessageHandler/syncStep2.js' import { readSyncStep2 } from './MessageHandler/syncStep2.js'
@ -7,6 +7,8 @@ import { integrateRemoteStructs } from './MessageHandler/integrateRemoteStructs.
import debug from 'debug' import debug from 'debug'
// TODO: rename Connector
export default class AbstractConnector { export default class AbstractConnector {
constructor (y, opts) { constructor (y, opts) {
this.y = y this.y = y

View File

@ -1,8 +1,15 @@
import { writeStructs } from './syncStep1.js' import { writeStructs } from './syncStep1.js'
import { integrateRemoteStructs } from './integrateRemoteStructs.js' import { integrateRemoteStructs } from './integrateRemoteStructs.js'
import { readDeleteSet, writeDeleteSet } from './deleteSet.js' import { readDeleteSet, writeDeleteSet } from './deleteSet.js'
import BinaryEncoder from '../Binary/Encoder.js' import BinaryEncoder from '../Util/Binary/Encoder.js'
/**
* Read the Decoder and fill the Yjs instance with data in the decoder.
*
* @param {Y} y The Yjs instance
* @param {BinaryDecoder} decoder The BinaryDecoder to read from.
*/
export function fromBinary (y, decoder) { export function fromBinary (y, decoder) {
y.transact(function () { y.transact(function () {
integrateRemoteStructs(y, decoder) integrateRemoteStructs(y, decoder)
@ -10,6 +17,13 @@ export function fromBinary (y, decoder) {
}) })
} }
/**
* Encode the Yjs model to binary format.
*
* @param {Y} y The Yjs instance
* @return {BinaryEncoder} The encoder instance that can be transformed
* to ArrayBuffer or Buffer.
*/
export function toBinary (y) { export function toBinary (y) {
let encoder = new BinaryEncoder() let encoder = new BinaryEncoder()
writeStructs(y, encoder, new Map()) writeStructs(y, encoder, new Map())

View File

@ -1,5 +1,5 @@
import { deleteItemRange } from '../Struct/Delete.js' import { deleteItemRange } from '../Struct/Delete.js'
import ID from '../Util/ID.js' import ID from '../Util/ID/ID.js'
export function stringifyDeleteSet (y, decoder, strBuilder) { export function stringifyDeleteSet (y, decoder, strBuilder) {
let dsLength = decoder.readUint32() let dsLength = decoder.readUint32()
@ -92,7 +92,7 @@ export function readDeleteSet (y, decoder) {
// delete maximum the len of d // delete maximum the len of d
// else delete as much as possible // else delete as much as possible
diff = Math.min(n._id.clock - d[0], d[1]) diff = Math.min(n._id.clock - d[0], d[1])
// deleteItemRange(y, user, d[0], diff) // deleteItemRange(y, user, d[0], diff, true)
deletions.push([user, d[0], diff]) deletions.push([user, d[0], diff])
} else { } else {
// 3) // 3)
@ -100,7 +100,7 @@ export function readDeleteSet (y, decoder) {
if (d[2] && !n.gc) { if (d[2] && !n.gc) {
// d marks as gc'd but n does not // d marks as gc'd but n does not
// then delete either way // then delete either way
// deleteItemRange(y, user, d[0], Math.min(diff, d[1])) // deleteItemRange(y, user, d[0], Math.min(diff, d[1]), true)
deletions.push([user, d[0], Math.min(diff, d[1])]) deletions.push([user, d[0], Math.min(diff, d[1])])
} }
} }
@ -117,12 +117,12 @@ export function readDeleteSet (y, decoder) {
// Adapt the Tree implementation to support delete while iterating // Adapt the Tree implementation to support delete while iterating
for (let i = deletions.length - 1; i >= 0; i--) { for (let i = deletions.length - 1; i >= 0; i--) {
const del = deletions[i] const del = deletions[i]
deleteItemRange(y, del[0], del[1], del[2]) deleteItemRange(y, del[0], del[1], del[2], true)
} }
// for the rest.. just apply it // for the rest.. just apply it
for (; pos < dv.length; pos++) { for (; pos < dv.length; pos++) {
d = dv[pos] d = dv[pos]
deleteItemRange(y, user, d[0], d[1]) deleteItemRange(y, user, d[0], d[1], true)
// deletions.push([user, d[0], d[1], d[2]]) // deletions.push([user, d[0], d[1], d[2]])
} }
} }

View File

@ -1,6 +1,7 @@
import { getStruct } from '../Util/structReferences.js' import { getStruct } from '../Util/structReferences.js'
import BinaryDecoder from '../Binary/Decoder.js' import BinaryDecoder from '../Util/Binary/Decoder.js'
import { logID } from './messageToString.js' import { logID } from './messageToString.js'
import GC from '../Struct/GC.js'
class MissingEntry { class MissingEntry {
constructor (decoder, missing, struct) { constructor (decoder, missing, struct) {
@ -11,6 +12,7 @@ class MissingEntry {
} }
/** /**
* @private
* Integrate remote struct * Integrate remote struct
* When a remote struct is integrated, other structs might be ready to ready to * When a remote struct is integrated, other structs might be ready to ready to
* integrate. * integrate.
@ -23,7 +25,14 @@ function _integrateRemoteStructHelper (y, struct) {
if (y.ss.getState(id.user) > id.clock) { if (y.ss.getState(id.user) > id.clock) {
return return
} }
if (struct.constructor === GC || (struct._parent.constructor !== GC && struct._parent._deleted === false)) {
// Is either a GC or Item with an undeleted parent
// save to integrate
struct._integrate(y) struct._integrate(y)
} else {
// Is an Item. parent was deleted.
struct._gc(y)
}
let msu = y._missingStructs.get(id.user) let msu = y._missingStructs.get(id.user)
if (msu != null) { if (msu != null) {
let clock = id.clock let clock = id.clock

View File

@ -1,9 +1,9 @@
import BinaryDecoder from '../Binary/Decoder.js' import BinaryDecoder from '../Util/Binary/Decoder.js'
import { stringifyStructs } from './integrateRemoteStructs.js' import { stringifyStructs } from './integrateRemoteStructs.js'
import { stringifySyncStep1 } from './syncStep1.js' import { stringifySyncStep1 } from './syncStep1.js'
import { stringifySyncStep2 } from './syncStep2.js' import { stringifySyncStep2 } from './syncStep2.js'
import ID from '../Util/ID.js' import ID from '../Util/ID/ID.js'
import RootID from '../Util/RootID.js' import RootID from '../Util/ID/RootID.js'
import Y from '../Y.js' import Y from '../Y.js'
export function messageToString ([y, buffer]) { export function messageToString ([y, buffer]) {
@ -46,3 +46,20 @@ export function logID (id) {
throw new Error('This is not a valid ID!') throw new Error('This is not a valid ID!')
} }
} }
/**
* Helper utility to convert an item to a readable format.
*
* @param {String} name The name of the item class (YText, ItemString, ..).
* @param {Item} item The item instance.
* @param {String} [append] Additional information to append to the returned
* string.
* @return {String} A readable string that represents the item object.
*
* @private
*/
export function logItemHelper (name, item, append) {
const left = item._left !== null ? item._left._lastId : null
const origin = item._origin !== null ? item._origin._lastId : null
return `${name}(id:${logID(item._id)},start:${logID(item._start)},left:${logID(left)},origin:${logID(origin)},right:${logID(item._right)},parent:${logID(item._parent)},parentSub:${item._parentSub}${append !== undefined ? ' - ' + append : ''})`
}

View File

@ -1,8 +1,8 @@
import BinaryEncoder from '../Binary/Encoder.js' import BinaryEncoder from '../Util/Binary/Encoder.js'
import { readStateSet, writeStateSet } from './stateSet.js' import { readStateSet, writeStateSet } from './stateSet.js'
import { writeDeleteSet } from './deleteSet.js' import { writeDeleteSet } from './deleteSet.js'
import ID from '../Util/ID.js' import ID from '../Util/ID/ID.js'
import { RootFakeUserID } from '../Util/RootID.js' import { RootFakeUserID } from '../Util/ID/RootID.js'
export function stringifySyncStep1 (y, decoder, strBuilder) { export function stringifySyncStep1 (y, decoder, strBuilder) {
let auth = decoder.readVarString() let auth = decoder.readVarString()
@ -30,6 +30,11 @@ export function sendSyncStep1 (connector, syncUser) {
connector.send(syncUser, encoder.createBuffer()) connector.send(syncUser, encoder.createBuffer())
} }
/**
* @private
* Write all Items that are not not included in ss to
* the encoder object.
*/
export function writeStructs (y, encoder, ss) { export function writeStructs (y, encoder, ss) {
const lenPos = encoder.pos const lenPos = encoder.pos
encoder.writeUint32(0) encoder.writeUint32(0)
@ -37,7 +42,15 @@ export function writeStructs (y, encoder, ss) {
for (let user of y.ss.state.keys()) { for (let user of y.ss.state.keys()) {
let clock = ss.get(user) || 0 let clock = ss.get(user) || 0
if (user !== RootFakeUserID) { if (user !== RootFakeUserID) {
y.os.iterate(new ID(user, clock), new ID(user, Number.MAX_VALUE), function (struct) { const minBound = new ID(user, clock)
const overlappingLeft = y.os.findPrev(minBound)
const rightID = overlappingLeft === null ? null : overlappingLeft._id
if (rightID !== null && rightID.user === user && rightID.clock + overlappingLeft._length > clock) {
const struct = overlappingLeft._clonePartial(clock - rightID.clock)
struct._toBinary(encoder)
len++
}
y.os.iterate(minBound, new ID(user, Number.MAX_VALUE), function (struct) {
struct._toBinary(encoder) struct._toBinary(encoder)
len++ len++
}) })

View File

@ -1,5 +1,5 @@
import BinaryEncoder from './Binary/Encoder.js' import BinaryEncoder from './Util/Binary/Encoder.js'
import BinaryDecoder from './Binary/Decoder.js' import BinaryDecoder from './Util/Binary/Decoder.js'
import { toBinary, fromBinary } from './MessageHandler/binaryEncode.js' import { toBinary, fromBinary } from './MessageHandler/binaryEncode.js'
import { integrateRemoteStructs } from './MessageHandler/integrateRemoteStructs.js' import { integrateRemoteStructs } from './MessageHandler/integrateRemoteStructs.js'
import { createMutualExclude } from './Util/mutualExclude.js' import { createMutualExclude } from './Util/mutualExclude.js'
@ -13,6 +13,9 @@ function getFreshCnf () {
} }
} }
/**
* Abstract persistence class.
*/
export default class AbstractPersistence { export default class AbstractPersistence {
constructor (opts) { constructor (opts) {
this.opts = opts this.opts = opts

View File

@ -1,5 +1,6 @@
import Tree from '../Util/Tree.js' import Tree from '../Util/Tree.js'
import ID from '../Util/ID.js' import ID from '../Util/ID/ID.js'
class DSNode { class DSNode {
constructor (id, len, gc) { constructor (id, len, gc) {
@ -29,97 +30,61 @@ export default class DeleteStore extends Tree {
var n = this.findWithUpperBound(id) var n = this.findWithUpperBound(id)
return n !== null && n._id.user === id.user && id.clock < n._id.clock + n.len return n !== null && n._id.user === id.user && id.clock < n._id.clock + n.len
} }
/* mark (id, length, gc) {
* Mark an operation as deleted. returns the deleted node if (length === 0) return
*/ // Step 1. Unmark range
const leftD = this.findWithUpperBound(new ID(id.user, id.clock - 1))
// Resize left DSNode if necessary
if (leftD !== null && leftD._id.user === id.user) {
if (leftD._id.clock < id.clock && id.clock < leftD._id.clock + leftD.len) {
// node is overlapping. need to resize
if (id.clock + length < leftD._id.clock + leftD.len) {
// overlaps new mark range and some more
// create another DSNode to the right of new mark
this.put(new DSNode(new ID(id.user, id.clock + length), leftD._id.clock + leftD.len - id.clock - length, leftD.gc))
}
// resize left DSNode
leftD.len = id.clock - leftD._id.clock
} // Otherwise there is no overlapping
}
// Resize right DSNode if necessary
const upper = new ID(id.user, id.clock + length - 1)
const rightD = this.findWithUpperBound(upper)
if (rightD !== null && rightD._id.user === id.user) {
if (rightD._id.clock < id.clock + length && id.clock <= rightD._id.clock && id.clock + length < rightD._id.clock + rightD.len) { // we only consider the case where we resize the node
const d = id.clock + length - rightD._id.clock
rightD._id = new ID(rightD._id.user, rightD._id.clock + d)
rightD.len -= d
}
}
// Now we only have to delete all inner marks
const deleteNodeIds = []
this.iterate(id, upper, m => {
deleteNodeIds.push(m._id)
})
for (let i = deleteNodeIds.length - 1; i >= 0; i--) {
this.delete(deleteNodeIds[i])
}
let newMark = new DSNode(id, length, gc)
// Step 2. Check if we can extend left or right
if (leftD !== null && leftD._id.user === id.user && leftD._id.clock + leftD.len === id.clock && leftD.gc === gc) {
// We can extend left
leftD.len += length
newMark = leftD
}
const rightNext = this.find(new ID(id.user, id.clock + length))
if (rightNext !== null && rightNext._id.user === id.user && id.clock + length === rightNext._id.clock && gc === rightNext.gc) {
// We can merge newMark and rightNext
newMark.len += rightNext.len
this.delete(rightNext._id)
}
if (leftD !== newMark) {
// only put if we didn't extend left
this.put(newMark)
}
}
// TODO: exchange markDeleted for mark()
markDeleted (id, length) { markDeleted (id, length) {
if (length == null) { this.mark(id, length, false)
throw new Error('length must be defined')
}
var n = this.findWithUpperBound(id)
if (n != null && n._id.user === id.user) {
if (n._id.clock <= id.clock && id.clock <= n._id.clock + n.len) {
// id is in n's range
var diff = id.clock + length - (n._id.clock + n.len) // overlapping right
if (diff > 0) {
// id+length overlaps n
if (!n.gc) {
n.len += diff
} else {
diff = n._id.clock + n.len - id.clock // overlapping left (id till n.end)
if (diff < length) {
// a partial deletion
let nId = id.clone()
nId.clock += diff
n = new DSNode(nId, length - diff, false)
this.put(n)
} else {
// already gc'd
throw new Error(
'DS reached an inconsistent state. Please report this issue!'
)
}
}
} else {
// no overlapping, already deleted
return n
}
} else {
// cannot extend left (there is no left!)
n = new DSNode(id, length, false)
this.put(n) // TODO: you double-put !!
}
} else {
// cannot extend left
n = new DSNode(id, length, false)
this.put(n)
}
// can extend right?
var next = this.findNext(n._id)
if (
next != null &&
n._id.user === next._id.user &&
n._id.clock + n.len >= next._id.clock
) {
diff = n._id.clock + n.len - next._id.clock // from next.start to n.end
while (diff >= 0) {
// n overlaps with next
if (next.gc) {
// gc is stronger, so reduce length of n
n.len -= diff
if (diff >= next.len) {
// delete the missing range after next
diff = diff - next.len // missing range after next
if (diff > 0) {
this.put(n) // unneccessary? TODO!
this.markDeleted(new ID(next._id.user, next._id.clock + next.len), diff)
}
}
break
} else {
// we can extend n with next
if (diff > next.len) {
// n is even longer than next
// get next.next, and try to extend it
var _next = this.findNext(next._id)
this.delete(next._id)
if (_next == null || n._id.user !== _next._id.user) {
break
} else {
next = _next
diff = n._id.clock + n.len - next._id.clock // from next.start to n.end
// continue!
}
} else {
// n just partially overlaps with next. extend n, delete next, and break this loop
n.len += next.len - diff
this.delete(next._id)
break
}
}
}
}
this.put(n)
return n
} }
} }

View File

@ -1,7 +1,8 @@
import Tree from '../Util/Tree.js' import Tree from '../Util/Tree.js'
import RootID from '../Util/RootID.js' import RootID from '../Util/ID/RootID.js'
import { getStruct } from '../Util/structReferences.js' import { getStruct } from '../Util/structReferences.js'
import { logID } from '../MessageHandler/messageToString.js' import { logID } from '../MessageHandler/messageToString.js'
import GC from '../Struct/GC.js'
export default class OperationStore extends Tree { export default class OperationStore extends Tree {
constructor (y) { constructor (y) {
@ -11,6 +12,13 @@ export default class OperationStore extends Tree {
logTable () { logTable () {
const items = [] const items = []
this.iterate(null, null, function (item) { this.iterate(null, null, function (item) {
if (item.constructor === GC) {
items.push({
id: logID(item),
content: item._length,
deleted: 'GC'
})
} else {
items.push({ items.push({
id: logID(item), id: logID(item),
origin: logID(item._origin === null ? null : item._origin._lastId), origin: logID(item._origin === null ? null : item._origin._lastId),
@ -22,6 +30,7 @@ export default class OperationStore extends Tree {
deleted: item._deleted, deleted: item._deleted,
content: JSON.stringify(item._content) content: JSON.stringify(item._content)
}) })
}
}) })
console.table(items) console.table(items)
} }

View File

@ -1,4 +1,4 @@
import ID from '../Util/ID.js' import ID from '../Util/ID/ID.js'
export default class StateStore { export default class StateStore {
constructor (y) { constructor (y) {

View File

@ -1,29 +1,30 @@
import { getReference } from '../Util/structReferences.js' import { getStructReference } from '../Util/structReferences.js'
import ID from '../Util/ID.js' import ID from '../Util/ID/ID.js'
import { logID } from '../MessageHandler/messageToString.js' import { logID } from '../MessageHandler/messageToString.js'
/** /**
* @private
* Delete all items in an ID-range * Delete all items in an ID-range
* TODO: implement getItemCleanStartNode for better performance (only one lookup) * TODO: implement getItemCleanStartNode for better performance (only one lookup)
*/ */
export function deleteItemRange (y, user, clock, range) { export function deleteItemRange (y, user, clock, range, gcChildren) {
const createDelete = y.connector !== null && y.connector._forwardAppliedStructs const createDelete = y.connector !== null && y.connector._forwardAppliedStructs
let item = y.os.getItemCleanStart(new ID(user, clock)) let item = y.os.getItemCleanStart(new ID(user, clock))
if (item !== null) { if (item !== null) {
if (!item._deleted) { if (!item._deleted) {
item._splitAt(y, range) item._splitAt(y, range)
item._delete(y, createDelete) item._delete(y, createDelete, true)
} }
let itemLen = item._length let itemLen = item._length
range -= itemLen range -= itemLen
clock += itemLen clock += itemLen
if (range > 0) { if (range > 0) {
let node = y.os.findNode(new ID(user, clock)) let node = y.os.findNode(new ID(user, clock))
while (node !== null && range > 0 && node.val._id.equals(new ID(user, clock))) { while (node !== null && node.val !== null && range > 0 && node.val._id.equals(new ID(user, clock))) {
const nodeVal = node.val const nodeVal = node.val
if (!nodeVal._deleted) { if (!nodeVal._deleted) {
nodeVal._splitAt(y, range) nodeVal._splitAt(y, range)
nodeVal._delete(y, createDelete) nodeVal._delete(y, createDelete, gcChildren)
} }
const nodeLen = nodeVal._length const nodeLen = nodeVal._length
range -= nodeLen range -= nodeLen
@ -35,13 +36,26 @@ export function deleteItemRange (y, user, clock, range) {
} }
/** /**
* Delete is not a real struct. It will not be saved in OS * @private
* A Delete change is not a real Item, but it provides the same interface as an
* Item. The only difference is that it will not be saved in the ItemStore
* (OperationStore), but instead it is safed in the DeleteStore.
*/ */
export default class Delete { export default class Delete {
constructor () { constructor () {
this._target = null this._target = null
this._length = null this._length = null
} }
/**
* @private
* 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 {BinaryDecoder} decoder The decoder object to read data from.
*/
_fromBinary (y, decoder) { _fromBinary (y, decoder) {
// TODO: set target, and add it to missing if not found // TODO: set target, and add it to missing if not found
// There is an edge case in p2p networks! // There is an edge case in p2p networks!
@ -54,22 +68,39 @@ export default class Delete {
return [] return []
} }
} }
/**
* @private
* 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 {BinaryEncoder} encoder The encoder to write data to.
*/
_toBinary (encoder) { _toBinary (encoder) {
encoder.writeUint8(getReference(this.constructor)) encoder.writeUint8(getStructReference(this.constructor))
encoder.writeID(this._targetID) encoder.writeID(this._targetID)
encoder.writeVarUint(this._length) encoder.writeVarUint(this._length)
} }
/** /**
* - If created remotely (a remote user deleted something), * @private
* Integrates this Item into the shared structure.
*
* This method actually applies the change to the Yjs instance. In the case of
* Delete it marks the delete target as deleted.
*
* * If created remotely (a remote user deleted something),
* this Delete is applied to all structs in id-range. * this Delete is applied to all structs in id-range.
* - If created lokally (e.g. when y-array deletes a range of elements), * * If created lokally (e.g. when y-array deletes a range of elements),
* this struct is broadcasted only (it is already executed) * this struct is broadcasted only (it is already executed)
*/ */
_integrate (y, locallyCreated = false) { _integrate (y, locallyCreated = false) {
if (!locallyCreated) { if (!locallyCreated) {
// from remote // from remote
const id = this._targetID const id = this._targetID
deleteItemRange(y, id.user, id.clock, this._length) deleteItemRange(y, id.user, id.clock, this._length, false)
} else if (y.connector !== null) { } else if (y.connector !== null) {
// from local // from local
y.connector.broadcastStruct(this) y.connector.broadcastStruct(this)
@ -78,6 +109,13 @@ export default class Delete {
y.persistence.saveStruct(y, this) y.persistence.saveStruct(y, this)
} }
} }
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () { _logString () {
return `Delete - target: ${logID(this._targetID)}, len: ${this._length}` return `Delete - target: ${logID(this._targetID)}, len: ${this._length}`
} }

94
src/Struct/GC.js Normal file
View File

@ -0,0 +1,94 @@
import { getStructReference } from '../Util/structReferences.js'
import { RootFakeUserID } from '../Util/ID/RootID.js'
import ID from '../Util/ID/ID.js'
// TODO should have the same base class as Item
export default class GC {
constructor () {
this._id = null
this._length = 0
}
get _deleted () {
return true
}
_integrate (y) {
const id = this._id
const userState = y.ss.getState(id.user)
if (id.clock === userState) {
y.ss.setState(id.user, id.clock + this._length)
}
y.ds.mark(this._id, this._length, true)
let n = y.os.put(this)
const prev = n.prev().val
if (prev !== null && prev.constructor === GC && prev._id.user === n.val._id.user && prev._id.clock + prev._length === n.val._id.clock) {
// TODO: do merging for all items!
prev._length += n.val._length
y.os.delete(n.val._id)
n = prev
}
if (n.val) {
n = n.val
}
const next = y.os.findNext(n._id)
if (next !== null && next.constructor === GC && next._id.user === n._id.user && next._id.clock === n._id.clock + n._length) {
n._length += next._length
y.os.delete(next._id)
}
if (id.user !== RootFakeUserID) {
if (y.connector !== null && (y.connector._forwardAppliedStructs || id.user === y.userID)) {
y.connector.broadcastStruct(this)
}
if (y.persistence !== null) {
y.persistence.saveStruct(y, this)
}
}
}
/**
* 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 {BinaryEncoder} encoder The encoder to write data to.
* @private
*/
_toBinary (encoder) {
encoder.writeUint8(getStructReference(this.constructor))
encoder.writeID(this._id)
encoder.writeVarUint(this._length)
}
/**
* 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 {BinaryDecoder} decoder The decoder object to read data from.
* @private
*/
_fromBinary (y, decoder) {
const id = decoder.readID()
this._id = id
this._length = decoder.readVarUint()
const missing = []
if (y.ss.getState(id.user) < id.clock) {
missing.push(new ID(id.user, id.clock - 1))
}
return missing
}
_splitAt () {
return this
}
_clonePartial (diff) {
const gc = new GC()
gc._id = new ID(this._id.user, this._id.clock + diff)
gc._length = this._length - diff
return gc
}
}

View File

@ -1,15 +1,17 @@
import { getReference } from '../Util/structReferences.js' import { getStructReference } from '../Util/structReferences.js'
import ID from '../Util/ID.js' import ID from '../Util/ID/ID.js'
import { RootFakeUserID } from '../Util/RootID.js' import { default as RootID, RootFakeUserID } from '../Util/ID/RootID.js'
import Delete from './Delete.js' import Delete from './Delete.js'
import { transactionTypeChanged } from '../Transaction.js' import { transactionTypeChanged } from '../Transaction.js'
import GC from './GC.js'
/** /**
* Helper utility to split an Item (see _splitAt) * @private
* - copy all properties from a to b * Helper utility to split an Item (see {@link Item#_splitAt})
* - connect a to b * - copies all properties from a to b
* - connects a to b
* - assigns the correct _id * - assigns the correct _id
* - save b to os * - saves b to os
*/ */
export function splitHelper (y, a, b, diff) { export function splitHelper (y, a, b, diff) {
const aID = a._id const aID = a._id
@ -39,28 +41,84 @@ export function splitHelper (y, a, b, diff) {
o = o._right o = o._right
} }
y.os.put(b) y.os.put(b)
if (y._transaction.newTypes.has(a)) {
y._transaction.newTypes.add(b)
} else if (y._transaction.deletedStructs.has(a)) {
y._transaction.deletedStructs.add(b)
}
} }
/**
* Abstract class that represents any content.
*/
export default class Item { export default class Item {
constructor () { constructor () {
/**
* The uniqe identifier of this type.
* @type {ID}
*/
this._id = null this._id = null
/**
* The item that was originally to the left of this item.
* @type {Item}
*/
this._origin = null this._origin = null
/**
* The item that is currently to the left of this item.
* @type {Item}
*/
this._left = null this._left = null
/**
* The item that is currently to the right of this item.
* @type {Item}
*/
this._right = null this._right = null
/**
* The item that was originally to the right of this item.
* @type {Item}
*/
this._right_origin = null this._right_origin = null
/**
* The parent type.
* @type {Y|YType}
*/
this._parent = null this._parent = null
/**
* If the parent refers to this item with some kind of key (e.g. YMap, the
* key is specified here. The key is then used to refer to the list in which
* to insert this item. If `parentSub = null` type._start is the list in
* which to insert to. Otherwise it is `parent._start`.
* @type {String}
*/
this._parentSub = null this._parentSub = null
/**
* Whether this item was deleted or not.
* @type {Boolean}
*/
this._deleted = false this._deleted = false
/**
* If this type's effect is reundone this type refers to the type that undid
* this operation.
* @type {Item}
*/
this._redone = null this._redone = null
} }
/** /**
* Create a operation with the same effect (without position effect) * Creates an Item with the same effect as this Item (without position effect)
*
* @private
*/ */
_copy () { _copy () {
return new this.constructor() return new this.constructor()
} }
/** /**
* Redo the effect of this operation. * Redoes the effect of this operation.
*
* @param {Y} y The Yjs instance.
*
* @private
*/ */
_redo (y) { _redo (y) {
if (this._redone !== null) { if (this._redone !== null) {
@ -102,20 +160,47 @@ export default class Item {
return struct return struct
} }
/**
* Computes the last content address of this Item.
*
* @private
*/
get _lastId () { get _lastId () {
return new ID(this._id.user, this._id.clock + this._length - 1) return new ID(this._id.user, this._id.clock + this._length - 1)
} }
/**
* Computes the length of this Item.
*
* @private
*/
get _length () { get _length () {
return 1 return 1
} }
/** /**
* Splits this struct so that another struct can be inserted in-between. * Should return false if this Item is some kind of meta information
* (e.g. format information).
*
* * Whether this Item should be addressable via `yarray.get(i)`
* * Whether this Item should be counted when computing yarray.length
*
* @private
*/
get _countable () {
return true
}
/**
* Splits this Item so that another Items can be inserted in-between.
* This must be overwritten if _length > 1 * This must be overwritten if _length > 1
* Returns right part after split * Returns right part after split
* - diff === 0 => this * * diff === 0 => this
* - diff === length => this._right * * diff === length => this._right
* - otherwise => split _content and return right part of split * * otherwise => split _content and return right part of split
* (see ItemJSON/ItemString for implementation) * (see {@link ItemJSON}/{@link ItemString} for implementation)
*
* @private
*/ */
_splitAt (y, diff) { _splitAt (y, diff) {
if (diff === 0) { if (diff === 0) {
@ -123,10 +208,20 @@ export default class Item {
} }
return this._right return this._right
} }
/**
* Mark this Item as deleted.
*
* @param {Y} y The Yjs instance
* @param {boolean} createDelete Whether to propagate a message that this
* Type was deleted.
*
* @private
*/
_delete (y, createDelete = true) { _delete (y, createDelete = true) {
if (!this._deleted) { if (!this._deleted) {
this._deleted = true this._deleted = true
y.ds.markDeleted(this._id, this._length) y.ds.mark(this._id, this._length, false)
let del = new Delete() let del = new Delete()
del._targetID = this._id del._targetID = this._id
del._length = this._length del._length = this._length
@ -141,17 +236,39 @@ export default class Item {
y._transaction.deletedStructs.add(this) y._transaction.deletedStructs.add(this)
} }
} }
_gcChildren (y) {}
_gc (y) {
const gc = new GC()
gc._id = this._id
gc._length = this._length
y.os.delete(this._id)
gc._integrate(y)
}
/** /**
* This is called right before this struct receives any children. * This is called right before this Item receives any children.
* It can be overwritten to apply pending changes before applying remote changes * It can be overwritten to apply pending changes before applying remote changes
*
* @private
*/ */
_beforeChange () { _beforeChange () {
// nop // nop
} }
/*
* - Integrate the struct so that other types/structs can see it /**
* - Add this struct to y.os * Integrates this Item into the shared structure.
* - Check if this is struct deleted *
* 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.
*
* * Integrate the struct so that other types/structs can see it
* * Add this struct to y.os
* * Check if this is struct deleted
*
* @private
*/ */
_integrate (y) { _integrate (y) {
y._transaction.newTypes.add(this) y._transaction.newTypes.add(this)
@ -177,6 +294,7 @@ export default class Item {
// or this types is new // or this types is new
this._parent._beforeChange() this._parent._beforeChange()
} }
/* /*
# $this has to find a unique position between origin and the next known character # $this has to find a unique position between origin and the next known character
# case 1: $origin equals $o.origin: the $creator parameter decides if left or right # case 1: $origin equals $o.origin: the $creator parameter decides if left or right
@ -269,8 +387,19 @@ export default class Item {
} }
} }
} }
/**
* 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 {BinaryEncoder} encoder The encoder to write data to.
*
* @private
*/
_toBinary (encoder) { _toBinary (encoder) {
encoder.writeUint8(getReference(this.constructor)) encoder.writeUint8(getStructReference(this.constructor))
let info = 0 let info = 0
if (this._origin !== null) { if (this._origin !== null) {
info += 0b1 // origin is defined info += 0b1 // origin is defined
@ -309,6 +438,17 @@ export default class Item {
encoder.writeVarString(JSON.stringify(this._parentSub)) encoder.writeVarString(JSON.stringify(this._parentSub))
} }
} }
/**
* 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 {BinaryDecoder} decoder The decoder object to read data from.
*
* @private
*/
_fromBinary (y, decoder) { _fromBinary (y, decoder) {
let missing = [] let missing = []
const info = decoder.readUint8() const info = decoder.readUint8()
@ -346,7 +486,12 @@ export default class Item {
const parentID = decoder.readID() const parentID = decoder.readID()
// parent does not change, so we don't have to search for it again // parent does not change, so we don't have to search for it again
if (this._parent === null) { if (this._parent === null) {
const parent = y.os.get(parentID) let parent
if (parentID.constructor === RootID) {
parent = y.os.get(parentID)
} else {
parent = y.os.getItem(parentID)
}
if (parent === null) { if (parent === null) {
missing.push(parentID) missing.push(parentID)
} else { } else {
@ -355,11 +500,21 @@ export default class Item {
} }
} else if (this._parent === null) { } else if (this._parent === null) {
if (this._origin !== null) { if (this._origin !== null) {
if (this._origin.constructor === GC) {
// if origin is a gc, set parent also gc'd
this._parent = this._origin
} else {
this._parent = this._origin._parent this._parent = this._origin._parent
}
} else if (this._right_origin !== null) { } else if (this._right_origin !== null) {
// if origin is a gc, set parent also gc'd
if (this._right_origin.constructor === GC) {
this._parent = this._right_origin
} else {
this._parent = this._right_origin._parent this._parent = this._right_origin._parent
} }
} }
}
if (info & 0b1000) { if (info & 0b1000) {
// TODO: maybe put this in read parent condition (you can also read parentsub from left/right) // TODO: maybe put this in read parent condition (you can also read parentsub from left/right)
this._parentSub = JSON.parse(decoder.readVarString()) this._parentSub = JSON.parse(decoder.readVarString())

35
src/Struct/ItemEmbed.js Normal file
View File

@ -0,0 +1,35 @@
import { default as Item } from './Item.js'
import { logItemHelper } from '../MessageHandler/messageToString.js'
export default class ItemEmbed extends Item {
constructor () {
super()
this.embed = null
}
_copy (undeleteChildren, copyPosition) {
let struct = super._copy(undeleteChildren, copyPosition)
struct.embed = this.embed
return struct
}
get _length () {
return 1
}
_fromBinary (y, decoder) {
const missing = super._fromBinary(y, decoder)
this.embed = JSON.parse(decoder.readVarString())
return missing
}
_toBinary (encoder) {
super._toBinary(encoder)
encoder.writeVarString(JSON.stringify(this.embed))
}
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () {
return logItemHelper('ItemEmbed', this, `embed:${JSON.stringify(this.embed)}`)
}
}

42
src/Struct/ItemFormat.js Normal file
View File

@ -0,0 +1,42 @@
import { default as Item } from './Item.js'
import { logItemHelper } from '../MessageHandler/messageToString.js'
export default class ItemFormat extends Item {
constructor () {
super()
this.key = null
this.value = null
}
_copy (undeleteChildren, copyPosition) {
let struct = super._copy(undeleteChildren, copyPosition)
struct.key = this.key
struct.value = this.value
return struct
}
get _length () {
return 1
}
get _countable () {
return false
}
_fromBinary (y, decoder) {
const missing = super._fromBinary(y, decoder)
this.key = decoder.readVarString()
this.value = JSON.parse(decoder.readVarString())
return missing
}
_toBinary (encoder) {
super._toBinary(encoder)
encoder.writeVarString(this.key)
encoder.writeVarString(JSON.stringify(this.value))
}
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () {
return logItemHelper('ItemFormat', this, `key:${JSON.stringify(this.key)},value:${JSON.stringify(this.value)}`)
}
}

View File

@ -1,5 +1,5 @@
import { splitHelper, default as Item } from './Item.js' import { splitHelper, default as Item } from './Item.js'
import { logID } from '../MessageHandler/messageToString.js' import { logItemHelper } from '../MessageHandler/messageToString.js'
export default class ItemJSON extends Item { export default class ItemJSON extends Item {
constructor () { constructor () {
@ -45,10 +45,14 @@ export default class ItemJSON extends Item {
encoder.writeVarString(encoded) encoder.writeVarString(encoded)
} }
} }
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () { _logString () {
const left = this._left !== null ? this._left._lastId : null return logItemHelper('ItemJSON', this, `content:${JSON.stringify(this._content)}`)
const origin = this._origin !== null ? this._origin._lastId : null
return `ItemJSON(id:${logID(this._id)},content:${JSON.stringify(this._content)},left:${logID(left)},origin:${logID(origin)},right:${logID(this._right)},parent:${logID(this._parent)},parentSub:${this._parentSub})`
} }
_splitAt (y, diff) { _splitAt (y, diff) {
if (diff === 0) { if (diff === 0) {

View File

@ -1,5 +1,5 @@
import { splitHelper, default as Item } from './Item.js' import { splitHelper, default as Item } from './Item.js'
import { logID } from '../MessageHandler/messageToString.js' import { logItemHelper } from '../MessageHandler/messageToString.js'
export default class ItemString extends Item { export default class ItemString extends Item {
constructor () { constructor () {
@ -23,10 +23,14 @@ export default class ItemString extends Item {
super._toBinary(encoder) super._toBinary(encoder)
encoder.writeVarString(this._content) encoder.writeVarString(this._content)
} }
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () { _logString () {
const left = this._left !== null ? this._left._lastId : null return logItemHelper('ItemString', this, `content:"${this._content}"`)
const origin = this._origin !== null ? this._origin._lastId : null
return `ItemJSON(id:${logID(this._id)},content:${JSON.stringify(this._content)},left:${logID(left)},origin:${logID(origin)},right:${logID(this._right)},parent:${logID(this._parent)},parentSub:${this._parentSub})`
} }
_splitAt (y, diff) { _splitAt (y, diff) {
if (diff === 0) { if (diff === 0) {

View File

@ -1,6 +1,6 @@
import Item from './Item.js' import Item from './Item.js'
import EventHandler from '../Util/EventHandler.js' import EventHandler from '../Util/EventHandler.js'
import ID from '../Util/ID.js' import ID from '../Util/ID/ID.js'
// restructure children as if they were inserted one after another // restructure children as if they were inserted one after another
function integrateChildren (y, start) { function integrateChildren (y, start) {
@ -30,6 +30,17 @@ export function getListItemIDByPosition (type, i) {
} }
} }
function gcChildren (y, item) {
while (item !== null) {
item._delete(y, false, true)
item._gc(y)
item = item._right
}
}
/**
* Abstract Yjs Type class
*/
export default class Type extends Item { export default class Type extends Item {
constructor () { constructor () {
super() super()
@ -39,32 +50,52 @@ export default class Type extends Item {
this._eventHandler = new EventHandler() this._eventHandler = new EventHandler()
this._deepEventHandler = new EventHandler() this._deepEventHandler = new EventHandler()
} }
/**
* Compute the path from this type to the specified target.
*
* @example
* It should be accessible via `this.get(result[0]).get(result[1])..`
* const path = type.getPathTo(child)
* // assuming `type instanceof YArray`
* console.log(path) // might look like => [2, 'key1']
* child === type.get(path[0]).get(path[1])
*
* @param {YType} type Type target
* @return {Array<string>} Path to the target
*/
getPathTo (type) { getPathTo (type) {
if (type === this) { if (type === this) {
return [] return []
} }
const path = [] const path = []
const y = this._y const y = this._y
while (type._parent !== this && this._parent !== y) { while (type !== this && type !== y) {
let parent = type._parent let parent = type._parent
if (type._parentSub !== null) { if (type._parentSub !== null) {
path.push(type._parentSub) path.unshift(type._parentSub)
} else { } else {
// parent is array-ish // parent is array-ish
for (let [i, child] of parent) { for (let [i, child] of parent) {
if (child === type) { if (child === type) {
path.push(i) path.unshift(i)
break break
} }
} }
} }
type = parent type = parent
} }
if (this._parent !== this) { if (type !== this) {
throw new Error('The type is not a child of this node') throw new Error('The type is not a child of this node')
} }
return path return path
} }
/**
* @private
* Call event listeners with an event. This will also add an event to all
* parents (for `.observeDeep` handlers).
*/
_callEventHandler (transaction, event) { _callEventHandler (transaction, event) {
const changedParentTypes = transaction.changedParentTypes const changedParentTypes = transaction.changedParentTypes
this._eventHandler.callEventListeners(transaction, event) this._eventHandler.callEventListeners(transaction, event)
@ -79,6 +110,14 @@ export default class Type extends Item {
type = type._parent type = type._parent
} }
} }
/**
* @private
* Helper method to transact if the y instance is available.
*
* TODO: Currently event handlers are not thrown when a type is not registered
* with a Yjs instance.
*/
_transact (f) { _transact (f) {
const y = this._y const y = this._y
if (y !== null) { if (y !== null) {
@ -87,18 +126,53 @@ export default class Type extends Item {
f(y) f(y)
} }
} }
/**
* Observe all events that are created on this type.
*
* @param {Function} f Observer function
*/
observe (f) { observe (f) {
this._eventHandler.addEventListener(f) this._eventHandler.addEventListener(f)
} }
/**
* Observe all events that are created by this type and its children.
*
* @param {Function} f Observer function
*/
observeDeep (f) { observeDeep (f) {
this._deepEventHandler.addEventListener(f) this._deepEventHandler.addEventListener(f)
} }
/**
* Unregister an observer function.
*
* @param {Function} f Observer function
*/
unobserve (f) { unobserve (f) {
this._eventHandler.removeEventListener(f) this._eventHandler.removeEventListener(f)
} }
/**
* Unregister an observer function.
*
* @param {Function} f Observer function
*/
unobserveDeep (f) { unobserveDeep (f) {
this._deepEventHandler.removeEventListener(f) this._deepEventHandler.removeEventListener(f)
} }
/**
* @private
* 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
*/
_integrate (y) { _integrate (y) {
super._integrate(y) super._integrate(y)
this._y = y this._y = y
@ -117,22 +191,53 @@ export default class Type extends Item {
integrateChildren(y, t) integrateChildren(y, t)
} }
} }
_delete (y, createDelete) {
super._delete(y, createDelete) _gcChildren (y) {
gcChildren(y, this._start)
this._start = null
this._map.forEach(item => {
gcChildren(y, item)
})
this._map = new Map()
}
_gc (y) {
this._gcChildren(y)
super._gc(y)
}
/**
* @private
* 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.
*/
_delete (y, createDelete, gcChildren) {
if (gcChildren === undefined) {
gcChildren = y._hasUndoManager === false
}
super._delete(y, createDelete, gcChildren)
y._transaction.changedTypes.delete(this) y._transaction.changedTypes.delete(this)
// delete map types // delete map types
for (let value of this._map.values()) { for (let value of this._map.values()) {
if (value instanceof Item && !value._deleted) { if (value instanceof Item && !value._deleted) {
value._delete(y, false) value._delete(y, false, gcChildren)
} }
} }
// delete array types // delete array types
let t = this._start let t = this._start
while (t !== null) { while (t !== null) {
if (!t._deleted) { if (!t._deleted) {
t._delete(y, false) t._delete(y, false, gcChildren)
} }
t = t._right t = t._right
} }
if (gcChildren) {
this._gcChildren(y)
}
} }
} }

View File

@ -1,18 +1,69 @@
/**
* A transaction is created for every change on the Yjs model. It is possible
* to bundle changes on the Yjs model in a single transaction to
* minimize the number on messages sent and the number of observer calls.
* If possible the user of this library should bundle as many changes as
* possible. Here is an example to illustrate the advantages of bundling:
*
* @example
* const map = y.define('map', YMap)
* // Log content when change is triggered
* map.observe(function () {
* console.log('change triggered')
* })
* // Each change on the map type triggers a log message:
* map.set('a', 0) // => "change triggered"
* map.set('b', 0) // => "change triggered"
* // When put in a transaction, it will trigger the log after the transaction:
* y.transact(function () {
* map.set('a', 1)
* map.set('b', 1)
* }) // => "change triggered"
*
*/
export default class Transaction { export default class Transaction {
constructor (y) { constructor (y) {
/**
* @type {Y} The Yjs instance.
*/
this.y = y this.y = y
// types added during transaction /**
* All new types that are added during a transaction.
* @type {Set<Item>}
*/
this.newTypes = new Set() this.newTypes = new Set()
// changed types (does not include new types) /**
// maps from type to parentSubs (item._parentSub = null for array elements) * All types that were directly modified (property added or child
* inserted/deleted). New types are not included in this Set.
* Maps from type to parentSubs (`item._parentSub = null` for YArray)
* @type {Set<YType,String>}
*/
this.changedTypes = new Map() this.changedTypes = new Map()
// TODO: rename deletedTypes
/**
* Set of all deleted Types and Structs.
* @type {Set<Item>}
*/
this.deletedStructs = new Set() this.deletedStructs = new Set()
/**
* Saves the old state set of the Yjs instance. If a state was modified,
* the original value is saved here.
* @type {Map<Number,Number>}
*/
this.beforeState = new Map() this.beforeState = new Map()
/**
* Stores the events for the types that observe also child elements.
* It is mainly used by `observeDeep`.
* @type {Map<YType,Array<YEvent>>}
*/
this.changedParentTypes = new Map() this.changedParentTypes = new Map()
} }
} }
/**
* @private
*/
export function transactionTypeChanged (y, type, sub) { export function transactionTypeChanged (y, type, sub) {
if (type !== y && !type._deleted && !y._transaction.newTypes.has(type)) { if (type !== y && !type._deleted && !y._transaction.newTypes.has(type)) {
const changedTypes = y._transaction.changedTypes const changedTypes = y._transaction.changedTypes

View File

@ -1,73 +0,0 @@
import ItemString from '../Struct/ItemString.js'
import YArray from './YArray.js'
import { logID } from '../MessageHandler/messageToString.js'
export default class YText extends YArray {
constructor (string) {
super()
if (typeof string === 'string') {
const start = new ItemString()
start._parent = this
start._content = string
this._start = start
}
}
toString () {
const strBuilder = []
let n = this._start
while (n !== null) {
if (!n._deleted) {
strBuilder.push(n._content)
}
n = n._right
}
return strBuilder.join('')
}
insert (pos, text) {
if (text.length <= 0) {
return
}
this._transact(y => {
let left = null
let right = this._start
let count = 0
while (right !== null) {
const rightLen = right._deleted ? 0 : (right._length - 1)
if (count <= pos && pos <= count + rightLen) {
const splitDiff = pos - count
right = right._splitAt(this._y, splitDiff)
left = right._left
count += splitDiff
break
}
if (!right._deleted) {
count += right._length
}
left = right
right = right._right
}
if (pos > count) {
throw new Error('Position exceeds array range!')
}
let item = new ItemString()
item._origin = left
item._left = left
item._right = right
item._right_origin = right
item._parent = this
item._content = text
if (y !== null) {
item._integrate(this._y)
} else if (left === null) {
this._start = item
} else {
left._right = item
}
})
}
_logString () {
const left = this._left !== null ? this._left._lastId : null
const origin = this._origin !== null ? this._origin._lastId : null
return `YText(id:${logID(this._id)},start:${logID(this._start)},left:${logID(left)},origin:${logID(origin)},right:${logID(this._right)},parent:${logID(this._parent)},parentSub:${this._parentSub})`
}
}

View File

@ -1,134 +0,0 @@
import { defaultDomFilter } from './utils.js'
import YMap from '../YMap.js'
import { YXmlFragment } from './y-xml.js'
export default class YXmlElement extends YXmlFragment {
constructor (arg1, arg2, _document) {
super()
this.nodeName = null
this._scrollElement = null
if (typeof arg2 === 'function') {
this._domFilter = arg2
}
if (typeof arg1 === 'string') {
this.nodeName = arg1.toUpperCase()
} else if (arg1 != null && arg1.nodeType != null && arg1.nodeType === arg1.ELEMENT_NODE) {
this.nodeName = arg1.nodeName
this._setDom(arg1, _document)
} else {
this.nodeName = 'UNDEFINED'
}
}
_copy () {
let struct = super._copy()
struct.nodeName = this.nodeName
return struct
}
_setDom (dom, _document) {
if (this._dom != null) {
throw new Error('Only call this method if you know what you are doing ;)')
} else if (dom._yxml != null) { // TODO do i need to check this? - no.. but for dev purps..
throw new Error('Already bound to an YXml type')
} else {
// tag is already set in constructor
// set attributes
let attributes = new Map()
for (let i = 0; i < dom.attributes.length; i++) {
let attr = dom.attributes[i]
// get attribute via getAttribute for custom element support (some write something different in attr.value)
attributes.set(attr.name, dom.getAttribute(attr.name))
}
attributes = this._domFilter(dom.nodeName, attributes)
attributes.forEach((value, name) => {
this.setAttribute(name, value)
})
this.insertDomElements(0, Array.prototype.slice.call(dom.childNodes), _document)
this._bindToDom(dom, _document)
return dom
}
}
_bindToDom (dom, _document) {
_document = _document || document
this._dom = dom
dom._yxml = this
}
_fromBinary (y, decoder) {
const missing = super._fromBinary(y, decoder)
this.nodeName = decoder.readVarString()
return missing
}
_toBinary (encoder) {
super._toBinary(encoder)
encoder.writeVarString(this.nodeName)
}
_integrate (y) {
if (this.nodeName === null) {
throw new Error('nodeName must be defined!')
}
if (this._domFilter === defaultDomFilter && this._parent._domFilter !== undefined) {
this._domFilter = this._parent._domFilter
}
super._integrate(y)
}
/**
* Returns the string representation of the XML document.
* The attributes are ordered by attribute-name, so you can easily use this
* method to compare YXmlElements
*/
toString () {
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.toString()}</${nodeName}>`
}
removeAttribute () {
return YMap.prototype.delete.apply(this, arguments)
}
setAttribute () {
return YMap.prototype.set.apply(this, arguments)
}
getAttribute () {
return YMap.prototype.get.apply(this, arguments)
}
getAttributes () {
const obj = {}
for (let [key, value] of this._map) {
if (!value._deleted) {
obj[key] = value._content[0]
}
}
return obj
}
getDom (_document) {
_document = _document || document
let dom = this._dom
if (dom == null) {
dom = _document.createElement(this.nodeName)
dom._yxml = this
let attrs = this.getAttributes()
for (let key in attrs) {
dom.setAttribute(key, attrs[key])
}
this.forEach(yxml => {
dom.appendChild(yxml.getDom(_document))
})
this._bindToDom(dom, _document)
}
return dom
}
}

View File

@ -1,17 +0,0 @@
import YEvent from '../../Util/YEvent.js'
export default class YXmlEvent extends YEvent {
constructor (target, subs, remote) {
super(target)
this.childListChanged = false
this.attributesChanged = new Set()
this.remote = remote
subs.forEach((sub) => {
if (sub === null) {
this.childListChanged = true
} else {
this.attributesChanged.add(sub)
}
})
}
}

View File

@ -1,354 +0,0 @@
/* global MutationObserver */
import { defaultDomFilter, applyChangesFromDom, reflectChangesOnDom } from './utils.js'
import { beforeTransactionSelectionFixer, afterTransactionSelectionFixer } from './selection.js'
import YArray from '../YArray.js'
import YXmlEvent from './YXmlEvent.js'
import { YXmlText, YXmlHook } from './y-xml'
import { logID } from '../../MessageHandler/messageToString.js'
import diff from '../../Util/simpleDiff.js'
function domToYXml (parent, doms, _document) {
const types = []
doms.forEach(d => {
if (d._yxml != null && d._yxml !== false) {
d._yxml._unbindFromDom()
}
if (parent._domFilter(d.nodeName, new Map()) !== null) {
let type
const hookName = d._yjsHook || (d.dataset != null ? d.dataset.yjsHook : undefined)
if (hookName !== undefined) {
type = new YXmlHook(hookName, d)
} else if (d.nodeType === d.TEXT_NODE) {
type = new YXmlText(d)
} else if (d.nodeType === d.ELEMENT_NODE) {
type = new YXmlFragment._YXmlElement(d, parent._domFilter, _document)
} else {
throw new Error('Unsupported node!')
}
// type.enableSmartScrolling(parent._scrollElement)
types.push(type)
} else {
d._yxml = false
}
})
return types
}
class YXmlTreeWalker {
constructor (root, f) {
this._filter = f || (() => true)
this._root = root
this._currentNode = root
this._firstCall = true
}
[Symbol.iterator] () {
return this
}
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 === YXmlFragment._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 }
}
}
}
export default class YXmlFragment extends YArray {
constructor () {
super()
this._dom = null
this._domFilter = defaultDomFilter
this._domObserver = null
// this function makes sure that either the
// dom event is executed, or the yjs observer is executed
var token = true
this._mutualExclude = f => {
if (token) {
token = false
try {
f()
} catch (e) {
console.error(e)
}
/*
if (this._domObserver !== null) {
this._domObserver.takeRecords()
}
*/
token = true
}
}
}
createTreeWalker (filter) {
return new YXmlTreeWalker(this, filter)
}
/**
* Retrieve first element that matches *query*
* Similar to DOM's querySelector, but only accepts a subset of its queries
*
* Query support:
* - tagname
* TODO:
* - id
* - attribute
*/
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
}
}
querySelectorAll (query) {
query = query.toUpperCase()
return Array.from(new YXmlTreeWalker(this, element => element.nodeName === query))
}
enableSmartScrolling (scrollElement) {
this._scrollElement = scrollElement
this.forEach(xml => {
xml.enableSmartScrolling(scrollElement)
})
}
setDomFilter (f) {
this._domFilter = f
let attributes = new Map()
if (this.getAttributes !== undefined) {
let attrs = this.getAttributes()
for (let key in attrs) {
attributes.set(key, attrs[key])
}
}
this._y.transact(() => {
let result = this._domFilter(this.nodeName, new Map(attributes))
if (result === null) {
this._delete(this._y)
} else {
attributes.forEach((value, key) => {
if (!result.has(key)) {
this.removeAttribute(key)
}
})
}
this.forEach(xml => {
xml.setDomFilter(f)
})
})
}
_callObserver (transaction, parentSubs, remote) {
this._callEventHandler(transaction, new YXmlEvent(this, parentSubs, remote))
}
toString () {
return this.map(xml => xml.toString()).join('')
}
_delete (y, createDelete) {
this._unbindFromDom()
super._delete(y, createDelete)
}
_unbindFromDom () {
if (this._domObserver != null) {
this._domObserver.disconnect()
this._domObserver = null
}
if (this._dom != null) {
this._dom._yxml = null
this._dom = null
}
if (this._beforeTransactionHandler !== undefined) {
this._y.off('beforeTransaction', this._beforeTransactionHandler)
}
}
insertDomElementsAfter (prev, doms, _document) {
const types = domToYXml(this, doms, _document)
this.insertAfter(prev, types)
return types
}
insertDomElements (pos, doms, _document) {
const types = domToYXml(this, doms, _document)
this.insert(pos, types)
return types
}
getDom () {
return this._dom
}
bindToDom (dom, _document) {
if (this._dom != null) {
this._unbindFromDom()
}
if (dom._yxml != null) {
dom._yxml._unbindFromDom()
}
dom.innerHTML = ''
this.forEach(t => {
dom.insertBefore(t.getDom(_document), null)
})
this._bindToDom(dom, _document)
}
// binds to a dom element
// Only call if dom and YXml are isomorph
_bindToDom (dom, _document) {
_document = _document || document
this._dom = dom
dom._yxml = this
if (this._parent === null) {
return
}
this._y.on('beforeTransaction', beforeTransactionSelectionFixer)
this._y.on('afterTransaction', afterTransactionSelectionFixer)
const applyFilter = (type) => {
if (type._deleted) {
return
}
// check if type is a child of this
let isChild = false
let p = type
while (p !== this._y) {
if (p === this) {
isChild = true
break
}
p = p._parent
}
if (!isChild) {
return
}
// filter attributes
let attributes = new Map()
if (type.getAttributes !== undefined) {
let attrs = type.getAttributes()
for (let key in attrs) {
attributes.set(key, attrs[key])
}
}
let result = this._domFilter(type.nodeName, new Map(attributes))
if (result === null) {
type._delete(this._y)
} else {
attributes.forEach((value, key) => {
if (!result.has(key)) {
type.removeAttribute(key)
}
})
}
}
this._y.on('beforeObserverCalls', function (y, transaction) {
// apply dom filter to new and changed types
transaction.changedTypes.forEach(function (subs, type) {
if (subs.size > 1 || !subs.has(null)) {
// only apply changes on attributes
applyFilter(type)
}
})
transaction.newTypes.forEach(applyFilter)
})
// Apply Y.Xml events to dom
this.observeDeep(events => {
reflectChangesOnDom.call(this, events, _document)
})
// Apply Dom changes on Y.Xml
if (typeof MutationObserver !== 'undefined') {
this._beforeTransactionHandler = () => {
this._domObserverListener(this._domObserver.takeRecords())
}
this._y.on('beforeTransaction', this._beforeTransactionHandler)
this._domObserverListener = mutations => {
this._mutualExclude(() => {
this._y.transact(() => {
let diffChildren = new Set()
mutations.forEach(mutation => {
const dom = mutation.target
const yxml = dom._yxml
if (yxml == null || yxml.constructor === YXmlHook) {
// dom element is filtered
return
}
switch (mutation.type) {
case 'characterData':
var change = diff(yxml.toString(), dom.nodeValue)
yxml.delete(change.pos, change.remove)
yxml.insert(change.pos, change.insert)
break
case 'attributes':
if (yxml.constructor === YXmlFragment) {
break
}
let name = mutation.attributeName
let val = dom.getAttribute(name)
// check if filter accepts attribute
let attributes = new Map()
attributes.set(name, val)
if (this._domFilter(dom.nodeName, attributes).size > 0 && yxml.constructor !== YXmlFragment) {
if (yxml.getAttribute(name) !== val) {
if (val == null) {
yxml.removeAttribute(name)
} else {
yxml.setAttribute(name, val)
}
}
}
break
case 'childList':
diffChildren.add(mutation.target)
break
}
})
for (let dom of diffChildren) {
if (dom.yOnChildrenChanged !== undefined) {
dom.yOnChildrenChanged()
}
if (dom._yxml != null && dom._yxml !== false) {
applyChangesFromDom(dom)
}
}
})
})
}
this._domObserver = new MutationObserver(this._domObserverListener)
this._domObserver.observe(dom, {
childList: true,
attributes: true,
characterData: true,
subtree: true
})
}
return dom
}
_logString () {
const left = this._left !== null ? this._left._lastId : null
const origin = this._origin !== null ? this._origin._lastId : null
return `YXml(id:${logID(this._id)},left:${logID(left)},origin:${logID(origin)},right:${this._right},parent:${logID(this._parent)},parentSub:${this._parentSub})`
}
}

View File

@ -1,59 +0,0 @@
import YMap from '../YMap.js'
import { getHook, addHook } from './hooks.js'
export default class YXmlHook extends YMap {
constructor (hookName, dom) {
super()
this._dom = null
this.hookName = null
if (hookName !== undefined) {
this.hookName = hookName
this._dom = dom
dom._yjsHook = hookName
dom._yxml = this
getHook(hookName).fillType(dom, this)
}
}
_copy () {
const struct = super._copy()
struct.hookName = this.hookName
return struct
}
getDom (_document) {
_document = _document || document
if (this._dom === null) {
const dom = getHook(this.hookName).createDom(this)
this._dom = dom
dom._yxml = this
dom._yjsHook = this.hookName
}
return this._dom
}
_unbindFromDom () {
this._dom._yxml = null
this._yxml = null
// TODO: cleanup hook?
}
_fromBinary (y, decoder) {
const missing = super._fromBinary(y, decoder)
this.hookName = decoder.readVarString()
return missing
}
_toBinary (encoder) {
super._toBinary(encoder)
encoder.writeVarString(this.hookName)
}
_integrate (y) {
if (this.hookName === null) {
throw new Error('hookName must be defined!')
}
super._integrate(y)
}
setDomFilter () {
// TODO: implement new modfilter method!
}
enableSmartScrolling () {
// TODO: implement new smartscrolling method!
}
}
YXmlHook.addHook = addHook

View File

@ -1,93 +0,0 @@
import YText from '../YText.js'
export default class YXmlText extends YText {
constructor (arg1) {
let dom = null
let initialText = null
if (arg1 != null) {
if (arg1.nodeType != null && arg1.nodeType === arg1.TEXT_NODE) {
dom = arg1
initialText = dom.nodeValue
} else if (typeof arg1 === 'string') {
initialText = arg1
}
}
super(initialText)
this._dom = null
this._domObserver = null
this._domObserverListener = null
this._scrollElement = null
if (dom !== null) {
this._setDom(arg1)
}
/*
var token = true
this._mutualExclude = f => {
if (token) {
token = false
try {
f()
} catch (e) {
console.error(e)
}
this._domObserver.takeRecords()
token = true
}
}
this.observe(event => {
if (this._dom != null) {
const dom = this._dom
this._mutualExclude(() => {
let anchorViewPosition = getAnchorViewPosition(this._scrollElement)
let anchorViewFix
if (anchorViewPosition !== null && (anchorViewPosition.anchor !== null || getBoundingClientRect(this._dom).top <= 0)) {
anchorViewFix = anchorViewPosition
} else {
anchorViewFix = null
}
dom.nodeValue = this.toString()
fixScrollPosition(this._scrollElement, anchorViewFix)
})
}
})
*/
}
setDomFilter () {}
enableSmartScrolling (scrollElement) {
this._scrollElement = scrollElement
}
_setDom (dom) {
if (this._dom != null) {
this._unbindFromDom()
}
if (dom._yxml != null) {
dom._yxml._unbindFromDom()
}
// set marker
this._dom = dom
dom._yxml = this
}
getDom (_document) {
_document = _document || document
if (this._dom === null) {
const dom = _document.createTextNode(this.toString())
this._setDom(dom)
return dom
}
return this._dom
}
_delete (y, createDelete) {
this._unbindFromDom()
super._delete(y, createDelete)
}
_unbindFromDom () {
if (this._domObserver != null) {
this._domObserver.disconnect()
this._domObserver = null
}
if (this._dom != null) {
this._dom._yxml = null
this._dom = null
}
}
}

View File

@ -1,51 +0,0 @@
const filterMap = new Map()
export function addFilter (type, filter) {
if (!filterMap.has(type)) {
filterMap.set(type, new Set())
}
const filters = filterMap.get(type)
filters.add(filter)
}
export function executeFilter (type) {
const y = type._y
let parent = type
const nodeName = type.nodeName
let attributes = new Map()
if (type.getAttributes !== undefined) {
let attrs = type.getAttributes()
for (let key in attrs) {
attributes.set(key, attrs[key])
}
}
let filteredAttributes = new Map(attributes)
// is not y, supports dom filtering
while (parent !== y && parent.setDomFilter != null) {
const filters = filterMap.get(parent)
if (filters !== undefined) {
for (let f of filters) {
filteredAttributes = f(nodeName, filteredAttributes)
if (filteredAttributes === null) {
break
}
}
if (filteredAttributes === null) {
break
}
}
parent = parent._parent
}
if (filteredAttributes === null) {
type._delete(y)
} else {
// iterate original attributes
attributes.forEach((value, key) => {
// delete all attributes that are not in filteredAttributes
if (!filteredAttributes.has(key)) {
type.removeAttribute(key)
}
})
}
}

View File

@ -1,14 +0,0 @@
const xmlHooks = {}
export function addHook (name, hook) {
xmlHooks[name] = hook
}
export function getHook (name) {
const hook = xmlHooks[name]
if (hook === undefined) {
throw new Error(`The hook "${name}" is not specified! You must not access this hook!`)
}
return hook
}

View File

@ -1,271 +0,0 @@
import { YXmlText, YXmlHook } from './y-xml.js'
export function defaultDomFilter (node, attributes) {
return attributes
}
export function getAnchorViewPosition (scrollElement) {
if (scrollElement == null) {
return null
}
let anchor = document.getSelection().anchorNode
if (anchor != null) {
let top = getBoundingClientRect(anchor).top
if (top >= 0 && top <= document.documentElement.clientHeight) {
return {
anchor: anchor,
top: top
}
}
}
return {
anchor: null,
scrollTop: scrollElement.scrollTop,
scrollHeight: scrollElement.scrollHeight
}
}
// get BoundingClientRect that works on text nodes
export function getBoundingClientRect (element) {
if (element.getBoundingClientRect != null) {
// is element node
return element.getBoundingClientRect()
} else {
// is text node
if (element.parentNode == null) {
// range requires that text nodes have a parent
let span = document.createElement('span')
span.appendChild(element)
}
let range = document.createRange()
range.selectNode(element)
return range.getBoundingClientRect()
}
}
export function fixScrollPosition (scrollElement, fix) {
if (scrollElement !== null && fix !== null) {
if (fix.anchor === null) {
if (scrollElement.scrollTop === fix.scrollTop) {
scrollElement.scrollTop = scrollElement.scrollHeight - fix.scrollHeight
}
} else {
scrollElement.scrollTop = getBoundingClientRect(fix.anchor).top - fix.top
}
}
}
function iterateUntilUndeleted (item) {
while (item !== null && item._deleted) {
item = item._right
}
return item
}
function _insertNodeHelper (yxml, prevExpectedNode, child) {
let insertedNodes = yxml.insertDomElementsAfter(prevExpectedNode, [child])
if (insertedNodes.length > 0) {
return insertedNodes[0]
} else {
return prevExpectedNode
}
}
/*
* 1. Check if any of the nodes was deleted
* 2. Iterate over the children.
* 2.1 If a node exists without _yxml property, insert a new node
* 2.2 If _contents.length < dom.childNodes.length, fill the
* rest of _content with childNodes
* 2.3 If a node was moved, delete it and
* recreate a new yxml element that is bound to that node.
* You can detect that a node was moved because expectedId
* !== actualId in the list
*/
export function applyChangesFromDom (dom) {
const yxml = dom._yxml
if (yxml.constructor === YXmlHook) {
return
}
const y = yxml._y
let knownChildren =
new Set(
Array.prototype.map.call(dom.childNodes, child => child._yxml)
.filter(id => id !== undefined)
)
// 1. Check if any of the nodes was deleted
yxml.forEach(function (childType, i) {
if (!knownChildren.has(childType)) {
childType._delete(y)
}
})
// 2. iterate
let childNodes = dom.childNodes
let len = childNodes.length
let prevExpectedNode = null
let expectedNode = iterateUntilUndeleted(yxml._start)
for (let domCnt = 0; domCnt < len; domCnt++) {
const child = childNodes[domCnt]
const childYXml = child._yxml
if (childYXml != null) {
if (childYXml === false) {
// should be ignored or is going to be deleted
continue
}
if (expectedNode !== null) {
if (expectedNode !== childYXml) {
// 2.3 Not expected node
if (childYXml._parent !== this) {
// element is going to be deleted by its previous parent
child._yxml = null
} else {
childYXml._delete(y)
}
prevExpectedNode = _insertNodeHelper(yxml, prevExpectedNode, child)
} else {
prevExpectedNode = expectedNode
expectedNode = iterateUntilUndeleted(expectedNode._right)
}
// if this is the expected node id, just continue
} else {
// 2.2 fill _conten with child nodes
prevExpectedNode = _insertNodeHelper(yxml, prevExpectedNode, child)
}
} else {
// 2.1 A new node was found
prevExpectedNode = _insertNodeHelper(yxml, prevExpectedNode, child)
}
}
}
export function reflectChangesOnDom (events, _document) {
// Make sure that no filtered attributes are applied to the structure
// if they were, delete them
/*
events.forEach(event => {
const target = event.target
if (event.attributesChanged === undefined) {
// event.target is Y.XmlText
return
}
const keys = this._domFilter(target.nodeName, Array.from(event.attributesChanged))
if (keys === null) {
target._delete()
} else {
const removeKeys = new Set() // is a copy of event.attributesChanged
event.attributesChanged.forEach(key => { removeKeys.add(key) })
keys.forEach(key => {
// remove all accepted keys from removeKeys
removeKeys.delete(key)
})
// remove the filtered attribute
removeKeys.forEach(key => {
target.removeAttribute(key)
})
}
})
*/
this._mutualExclude(() => {
events.forEach(event => {
const yxml = event.target
const dom = yxml._dom
if (dom != null) {
// TODO: do this once before applying stuff
// let anchorViewPosition = getAnchorViewPosition(yxml._scrollElement)
if (yxml.constructor === YXmlText) {
yxml._dom.nodeValue = yxml.toString()
} else if (event.attributesChanged !== undefined) {
// update attributes
event.attributesChanged.forEach(attributeName => {
const value = yxml.getAttribute(attributeName)
if (value === undefined) {
dom.removeAttribute(attributeName)
} else {
dom.setAttribute(attributeName, value)
}
})
/**
* TODO: instead of chard-checking the types, it would be best to
* specify the type's features. E.g.
* - _yxmlHasAttributes
* - _yxmlHasChildren
* Furthermore, the features shouldn't be encoded in the types,
* only in the attributes (above)
*/
if (event.childListChanged && yxml.constructor !== YXmlHook) {
let currentChild = dom.firstChild
yxml.forEach(function (t) {
let expectedChild = t.getDom(_document)
if (expectedChild.parentNode === dom) {
// is already attached to the dom. Look for it
while (currentChild !== expectedChild) {
let del = currentChild
currentChild = currentChild.nextSibling
dom.removeChild(del)
}
currentChild = currentChild.nextSibling
} else {
// this dom is not yet attached to dom
dom.insertBefore(expectedChild, currentChild)
}
})
while (currentChild !== null) {
let tmp = currentChild.nextSibling
dom.removeChild(currentChild)
currentChild = tmp
}
}
}
/* TODO: smartscrolling
.. else if (event.type === 'childInserted' || event.type === 'insert') {
let nodes = event.values
for (let i = nodes.length - 1; i >= 0; i--) {
let node = nodes[i]
node.setDomFilter(yxml._domFilter)
node.enableSmartScrolling(yxml._scrollElement)
let dom = node.getDom()
let fixPosition = null
let nextDom = null
if (yxml._content.length > event.index + i + 1) {
nextDom = yxml.get(event.index + i + 1).getDom()
}
yxml._dom.insertBefore(dom, nextDom)
if (anchorViewPosition === null) {
// nop
} else if (anchorViewPosition.anchor !== null) {
// no scrolling when current selection
if (!dom.contains(anchorViewPosition.anchor) && !anchorViewPosition.anchor.contains(dom)) {
fixPosition = anchorViewPosition
}
} else if (getBoundingClientRect(dom).top <= 0) {
// adjust scrolling if modified element is out of view,
// there is no anchor element, and the browser did not adjust scrollTop (this is checked later)
fixPosition = anchorViewPosition
}
fixScrollPosition(yxml._scrollElement, fixPosition)
}
} else if (event.type === 'childRemoved' || event.type === 'delete') {
for (let i = event.values.length - 1; i >= 0; i--) {
let dom = event.values[i]._dom
let fixPosition = null
if (anchorViewPosition === null) {
// nop
} else if (anchorViewPosition.anchor !== null) {
// no scrolling when current selection
if (!dom.contains(anchorViewPosition.anchor) && !anchorViewPosition.anchor.contains(dom)) {
fixPosition = anchorViewPosition
}
} else if (getBoundingClientRect(dom).top <= 0) {
// adjust scrolling if modified element is out of view,
// there is no anchor element, and the browser did not adjust scrollTop (this is checked later)
fixPosition = anchorViewPosition
}
dom.remove()
fixScrollPosition(yxml._scrollElement, fixPosition)
}
}
*/
}
})
})
}

View File

@ -1,16 +1,30 @@
import Type from '../Struct/Type.js' import Type from '../../Struct/Type.js'
import ItemJSON from '../Struct/ItemJSON.js' import ItemJSON from '../../Struct/ItemJSON.js'
import ItemString from '../Struct/ItemString.js' import ItemString from '../../Struct/ItemString.js'
import { logID } from '../MessageHandler/messageToString.js' import { logID, logItemHelper } from '../../MessageHandler/messageToString.js'
import YEvent from '../Util/YEvent.js' import YEvent from '../../Util/YEvent.js'
class YArrayEvent extends YEvent { /**
* Event that describes the changes on a YArray
*
* @param {YArray} yarray The changed type
* @param {Boolean} remote Whether the changed was caused by a remote peer
* @param {Transaction} transaction The transaction object
*/
export class YArrayEvent extends YEvent {
constructor (yarray, remote, transaction) { constructor (yarray, remote, transaction) {
super(yarray) super(yarray)
this.remote = remote this.remote = remote
this._transaction = transaction this._transaction = transaction
this._addedElements = null this._addedElements = null
this._removedElements = null
} }
/**
* Child elements that were added in this transaction.
*
* @return {Set}
*/
get addedElements () { get addedElements () {
if (this._addedElements === null) { if (this._addedElements === null) {
const target = this.target const target = this.target
@ -25,7 +39,14 @@ class YArrayEvent extends YEvent {
} }
return this._addedElements return this._addedElements
} }
/**
* Child elements that were removed in this transaction.
*
* @return {Set}
*/
get removedElements () { get removedElements () {
if (this._removedElements === null) {
const target = this.target const target = this.target
const transaction = this._transaction const transaction = this._transaction
const removedElements = new Set() const removedElements = new Set()
@ -34,33 +55,60 @@ class YArrayEvent extends YEvent {
removedElements.add(struct) removedElements.add(struct)
} }
}) })
return removedElements this._removedElements = removedElements
}
return this._removedElements
} }
} }
/**
* A shared Array implementation.
*/
export default class YArray extends Type { export default class YArray extends Type {
/**
* @private
* Creates YArray Event and calls observers.
*/
_callObserver (transaction, parentSubs, remote) { _callObserver (transaction, parentSubs, remote) {
this._callEventHandler(transaction, new YArrayEvent(this, remote, transaction)) this._callEventHandler(transaction, new YArrayEvent(this, remote, transaction))
} }
get (pos) {
/**
* Returns the i-th element from a YArray.
*
* @param {Integer} index The index of the element to return from the YArray
*/
get (index) {
let n = this._start let n = this._start
while (n !== null) { while (n !== null) {
if (!n._deleted) { if (!n._deleted && n._countable) {
if (pos < n._length) { if (index < n._length) {
if (n.constructor === ItemJSON || n.constructor === ItemString) { if (n.constructor === ItemJSON || n.constructor === ItemString) {
return n._content[pos] return n._content[index]
} else { } else {
return n return n
} }
} }
pos -= n._length index -= n._length
} }
n = n._right n = n._right
} }
} }
/**
* Transforms this YArray to a JavaScript Array.
*
* @return {Array}
*/
toArray () { toArray () {
return this.map(c => c) return this.map(c => c)
} }
/**
* Transforms this Shared Type to a JSON object.
*
* @return {Array}
*/
toJSON () { toJSON () {
return this.map(c => { return this.map(c => {
if (c instanceof Type) { if (c instanceof Type) {
@ -73,6 +121,15 @@ export default class YArray extends Type {
return c 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
* @return {Array} A new array with each element being the result of the
* callback function
*/
map (f) { map (f) {
const res = [] const res = []
this.forEach((c, i) => { this.forEach((c, i) => {
@ -80,36 +137,47 @@ export default class YArray extends Type {
}) })
return res 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.
*/
forEach (f) { forEach (f) {
let pos = 0 let index = 0
let n = this._start let n = this._start
while (n !== null) { while (n !== null) {
if (!n._deleted) { if (!n._deleted && n._countable) {
if (n instanceof Type) { if (n instanceof Type) {
f(n, pos++, this) f(n, index++, this)
} else { } else {
const content = n._content const content = n._content
const contentLen = content.length const contentLen = content.length
for (let i = 0; i < contentLen; i++) { for (let i = 0; i < contentLen; i++) {
pos++ index++
f(content[i], pos, this) f(content[i], index, this)
} }
} }
} }
n = n._right n = n._right
} }
} }
/**
* Computes the length of this YArray.
*/
get length () { get length () {
let length = 0 let length = 0
let n = this._start let n = this._start
while (n !== null) { while (n !== null) {
if (!n._deleted) { if (!n._deleted && n._countable) {
length += n._length length += n._length
} }
n = n._right n = n._right
} }
return length return length
} }
[Symbol.iterator] () { [Symbol.iterator] () {
return { return {
next: function () { next: function () {
@ -130,7 +198,7 @@ export default class YArray extends Type {
content = this._item._content[this._itemElement++] content = this._item._content[this._itemElement++]
} }
return { return {
value: [this._count, content], value: content,
done: false done: false
} }
}, },
@ -139,14 +207,21 @@ export default class YArray extends Type {
_count: 0 _count: 0
} }
} }
delete (pos, length = 1) {
/**
* Deletes elements starting from an index.
*
* @param {Integer} index Index at which to start deleting elements
* @param {Integer} length The number of elements to remove. Defaults to 1.
*/
delete (index, length = 1) {
this._y.transact(() => { this._y.transact(() => {
let item = this._start let item = this._start
let count = 0 let count = 0
while (item !== null && length > 0) { while (item !== null && length > 0) {
if (!item._deleted) { if (!item._deleted && item._countable) {
if (count <= pos && pos < count + item._length) { if (count <= index && index < count + item._length) {
const diffDel = pos - count const diffDel = index - count
item = item._splitAt(this._y, diffDel) item = item._splitAt(this._y, diffDel)
item._splitAt(this._y, length) item._splitAt(this._y, length)
length -= item._length length -= item._length
@ -163,6 +238,14 @@ export default class YArray extends Type {
throw new Error('Delete exceeds the range of the YArray') throw new Error('Delete exceeds the range of the YArray')
} }
} }
/**
* @private
* Inserts content after an element container.
*
* @param {Item} left The element container to use as a reference.
* @param {Array} content The Array of content to insert (see {@see insert})
*/
insertAfter (left, content) { insertAfter (left, content) {
this._transact(y => { this._transact(y => {
let right let right
@ -219,16 +302,35 @@ export default class YArray extends Type {
} }
} }
}) })
return content
} }
insert (pos, 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 {Integer} index The index to insert content at.
* @param {Array} content The array of content
*/
insert (index, content) {
this._transact(() => {
let left = null let left = null
let right = this._start let right = this._start
let count = 0 let count = 0
const y = this._y const y = this._y
while (right !== null) { while (right !== null) {
const rightLen = right._deleted ? 0 : (right._length - 1) const rightLen = right._deleted ? 0 : (right._length - 1)
if (count <= pos && pos <= count + rightLen) { if (count <= index && index <= count + rightLen) {
const splitDiff = pos - count const splitDiff = index - count
right = right._splitAt(y, splitDiff) right = right._splitAt(y, splitDiff)
left = right._left left = right._left
count += splitDiff count += splitDiff
@ -240,11 +342,18 @@ export default class YArray extends Type {
left = right left = right
right = right._right right = right._right
} }
if (pos > count) { if (index > count) {
throw new Error('Position exceeds array range!') throw new Error('Index exceeds array range!')
} }
this.insertAfter(left, content) this.insertAfter(left, content)
})
} }
/**
* Appends content to this YArray.
*
* @param {Array} content Array of content to append.
*/
push (content) { push (content) {
let n = this._start let n = this._start
let lastUndeleted = null let lastUndeleted = null
@ -256,9 +365,14 @@ export default class YArray extends Type {
} }
this.insertAfter(lastUndeleted, content) 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 () { _logString () {
const left = this._left !== null ? this._left._lastId : null return logItemHelper('YArray', this, `start:${logID(this._start)}"`)
const origin = this._origin !== null ? this._origin._lastId : null
return `YArray(id:${logID(this._id)},start:${logID(this._start)},left:${logID(left)},origin:${logID(origin)},right:${logID(this._right)},parent:${logID(this._parent)},parentSub:${this._parentSub})`
} }
} }

View File

@ -1,10 +1,17 @@
import Type from '../Struct/Type.js' import Type from '../../Struct/Type.js'
import Item from '../Struct/Item.js' import Item from '../../Struct/Item.js'
import ItemJSON from '../Struct/ItemJSON.js' import ItemJSON from '../../Struct/ItemJSON.js'
import { logID } from '../MessageHandler/messageToString.js' import { logItemHelper } from '../../MessageHandler/messageToString.js'
import YEvent from '../Util/YEvent.js' import YEvent from '../../Util/YEvent.js'
class YMapEvent extends YEvent { /**
* Event that describes the changes on a YMap.
*
* @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.
*/
export class YMapEvent extends YEvent {
constructor (ymap, subs, remote) { constructor (ymap, subs, remote) {
super(ymap) super(ymap)
this.keysChanged = subs this.keysChanged = subs
@ -12,10 +19,23 @@ class YMapEvent extends YEvent {
} }
} }
/**
* A shared Map implementation.
*/
export default class YMap extends Type { export default class YMap extends Type {
/**
* @private
* Creates YMap Event and calls observers.
*/
_callObserver (transaction, parentSubs, remote) { _callObserver (transaction, parentSubs, remote) {
this._callEventHandler(transaction, new YMapEvent(this, parentSubs, remote)) this._callEventHandler(transaction, new YMapEvent(this, parentSubs, remote))
} }
/**
* Transforms this Shared Type to a JSON object.
*
* @return {Object}
*/
toJSON () { toJSON () {
const map = {} const map = {}
for (let [key, item] of this._map) { for (let [key, item] of this._map) {
@ -35,7 +55,14 @@ export default class YMap extends Type {
} }
return map return map
} }
/**
* Returns the keys for each element in the YMap Type.
*
* @return {Array}
*/
keys () { keys () {
// TODO: Should return either Iterator or Set!
let keys = [] let keys = []
for (let [key, value] of this._map) { for (let [key, value] of this._map) {
if (!value._deleted) { if (!value._deleted) {
@ -44,6 +71,12 @@ export default class YMap extends Type {
} }
return keys return keys
} }
/**
* Remove a specified element from this YMap.
*
* @param {encodable} key The key of the element to remove.
*/
delete (key) { delete (key) {
this._transact((y) => { this._transact((y) => {
let c = this._map.get(key) let c = this._map.get(key)
@ -52,11 +85,22 @@ export default class YMap extends Type {
} }
}) })
} }
/**
* Adds or updates an element with a specified key and value.
*
* @param {encodable} key The key of the element to add to this YMap.
* @param {encodable | YType} value The value of the element to add to this
* YMap.
*/
set (key, value) { set (key, value) {
this._transact(y => { this._transact(y => {
const old = this._map.get(key) || null const old = this._map.get(key) || null
if (old !== null) { if (old !== null) {
if (old.constructor === ItemJSON && !old._deleted && old._content[0] === value) { if (
old.constructor === ItemJSON &&
!old._deleted && old._content[0] === value
) {
// Trying to overwrite with same value // Trying to overwrite with same value
// break here // break here
return value return value
@ -87,6 +131,12 @@ export default class YMap extends Type {
}) })
return value return value
} }
/**
* Returns a specified element from this YMap.
*
* @param {encodable} key The key of the element to return.
*/
get (key) { get (key) {
let v = this._map.get(key) let v = this._map.get(key)
if (v === undefined || v._deleted) { if (v === undefined || v._deleted) {
@ -98,6 +148,12 @@ export default class YMap extends Type {
return v._content[v._content.length - 1] return v._content[v._content.length - 1]
} }
} }
/**
* Returns a boolean indicating whether the specified key exists or not.
*
* @param {encodable} key The key to test.
*/
has (key) { has (key) {
let v = this._map.get(key) let v = this._map.get(key)
if (v === undefined || v._deleted) { if (v === undefined || v._deleted) {
@ -106,9 +162,14 @@ export default class YMap extends Type {
return true return true
} }
} }
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () { _logString () {
const left = this._left !== null ? this._left._lastId : null return logItemHelper('YMap', this, `mapSize:${this._map.size}`)
const origin = this._origin !== null ? this._origin._lastId : null
return `YMap(id:${logID(this._id)},mapSize:${this._map.size},left:${logID(left)},origin:${logID(origin)},right:${logID(this._right)},parent:${logID(this._parent)},parentSub:${this._parentSub})`
} }
} }

654
src/Types/YText/YText.js Normal file
View File

@ -0,0 +1,654 @@
import ItemString from '../../Struct/ItemString.js'
import ItemEmbed from '../../Struct/ItemEmbed.js'
import ItemFormat from '../../Struct/ItemFormat.js'
import { logItemHelper } from '../../MessageHandler/messageToString.js'
import { YArrayEvent, default as YArray } from '../YArray/YArray.js'
/**
* @private
*/
function 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
*/
function 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
*/
function 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
*/
function 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
*/
function updateCurrentAttributes (currentAttributes, item) {
const value = item.value
const key = item.key
if (value === null) {
currentAttributes.delete(key)
} else {
currentAttributes.set(key, value)
}
}
/**
* @private
*/
function 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
*/
function 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
*/
function insertText (y, text, parent, left, right, currentAttributes, attributes) {
for (let [key] of currentAttributes) {
if (attributes.hasOwnProperty(key) === false) {
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
*/
function 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:
if (attributes.hasOwnProperty(right.key)) {
if (attributes[right.key] === 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
*/
function 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) {
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)
if (attributes.hasOwnProperty(item.key)) {
if (attributes[item.key] !== 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.hasOwnProperty('retain') && !lastOp.hasOwnProperty('attributes')) {
// 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*).
*
* @param {String} string The initial value of the YText.
*/
export default class YText extends YArray {
constructor (string) {
super()
if (typeof string === 'string') {
const start = new ItemString()
start._parent = this
start._content = string
this._start = start
}
}
/**
* @private
* Creates YMap Event and calls observers.
*/
_callObserver (transaction, parentSubs, remote) {
this._callEventHandler(transaction, new YTextEvent(this, remote, transaction))
}
/**
* Returns the unformatted string representation of this YText type.
*
* @public
*/
toString () {
let str = ''
let n = this._start
while (n !== null) {
if (!n._deleted && n._countable) {
str += n._content
}
n = n._right
}
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.hasOwnProperty('insert')) {
;[left, right] = insertText(y, op.insert, this, left, right, currentAttributes, op.attributes || {})
} else if (op.hasOwnProperty('retain')) {
;[left, right] = formatText(y, op.retain, this, left, right, currentAttributes, op.attributes || {})
} else if (op.hasOwnProperty('delete')) {
;[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 = ''
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 {Integer} 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 {Integer} 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 {Integer} index Index at which to start deleting.
* @param {Integer} 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 {Integer} index The position where to start formatting.
* @param {Integer} 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)
}
}

View File

@ -0,0 +1,188 @@
import YMap from '../YMap/YMap.js'
import { YXmlFragment } from './YXml.js'
import { createAssociation } from '../../Bindings/DomBinding/util.js'
/**
* 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
*
* @param {String} nodeName Node name
*/
export default class YXmlElement extends YXmlFragment {
constructor (nodeName = 'UNDEFINED') {
super()
this.nodeName = nodeName.toUpperCase()
}
/**
* @private
* Creates an Item with the same effect as this Item (without position effect)
*/
_copy () {
let struct = super._copy()
struct.nodeName = this.nodeName
return struct
}
/**
* @private
* 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 {BinaryDecoder} decoder The decoder object to read data from.
*/
_fromBinary (y, decoder) {
const missing = super._fromBinary(y, decoder)
this.nodeName = decoder.readVarString()
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 {BinaryEncoder} encoder The encoder to write data to.
*
* @private
*/
_toBinary (encoder) {
super._toBinary(encoder)
encoder.writeVarString(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
*
* @param {Y} y The Yjs instance
*
* @private
*/
_integrate (y) {
if (this.nodeName === null) {
throw new Error('nodeName must be defined!')
}
super._integrate(y)
}
/**
* 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
*/
toString () {
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.toString()}</${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.
* @return {String} The queried attribute value.
*
* @public
*/
getAttribute (attributeName) {
return YMap.prototype.get.call(this, attributeName)
}
/**
* Returns all attribute name/value pairs in a JSON Object.
*
* @return {Object} A JSON Object that describes the attributes.
*
* @public
*/
getAttributes () {
const obj = {}
for (let [key, value] of this._map) {
if (!value._deleted) {
obj[key] = value._content[0]
}
}
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<key:hookDefinition>} [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))
})
createAssociation(binding, dom, this)
return dom
}
}

View File

@ -0,0 +1,47 @@
import YEvent from '../../Util/YEvent.js'
/**
* An Event that describes changes on a YXml Element or Yxml Fragment
*
* @protected
*/
export default class YXmlEvent extends YEvent {
/**
* @param {YType} 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)
}
})
}
}

View File

@ -0,0 +1,167 @@
import { createAssociation } from '../../Bindings/DomBinding/util.js'
import YXmlTreeWalker from './YXmlTreeWalker.js'
import YArray from '../YArray/YArray.js'
import YXmlEvent from './YXmlEvent.js'
import { logItemHelper } from '../../MessageHandler/messageToString.js'
/**
* 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
*/
/**
* 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 default 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 {TreeWalker} 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))
}
/**
* Get the string representation of all the children of this YXmlFragment.
*
* @return {string} The string representation of all children.
*/
toString () {
return this.map(xml => xml.toString()).join('')
}
/**
* @private
* Unbind from Dom and 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)
}
/**
* 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<key:hookDefinition>} [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 fragment = _document.createDocumentFragment()
createAssociation(binding, 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 logItemHelper('YXml', this)
}
}

108
src/Types/YXml/YXmlHook.js Normal file
View File

@ -0,0 +1,108 @@
import YMap from '../YMap/YMap.js'
import { createAssociation } from '../../Bindings/DomBinding/util.js'
/**
* You can manage binding to a custom type with YXmlHook.
*
* @public
*/
export default 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<key:hookDefinition>} [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)
createAssociation(binding, 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 {BinaryDecoder} decoder The decoder object to read data from.
*
* @private
*/
_fromBinary (y, decoder) {
const missing = super._fromBinary(y, decoder)
this.hookName = decoder.readVarString()
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 {BinaryEncoder} encoder The encoder to write data to.
*
* @private
*/
_toBinary (encoder) {
super._toBinary(encoder)
encoder.writeVarString(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)
}
}

View File

@ -0,0 +1,46 @@
import YText from '../YText/YText.js'
import { createAssociation } from '../../Bindings/DomBinding/util.js'
/**
* Represents text in a Dom Element. In the future this type will also handle
* simple formatting information like bold and italic.
*
* @param {String} arg1 Initial value.
*/
export default 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<key:hookDefinition>} [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.createTextNode(this.toString())
createAssociation(binding, 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)
}
}

View File

@ -0,0 +1,76 @@
import YXmlFragment from './YXmlFragment.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 default 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 === YXmlFragment._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 }
}
}
}

View File

@ -1,46 +1,65 @@
import ID from '../Util/ID.js' import ID from '../ID/ID.js'
import { default as RootID, RootFakeUserID } from '../Util/RootID.js' import { default as RootID, RootFakeUserID } from '../ID/RootID.js'
/**
* A BinaryDecoder handles the decoding of an ArrayBuffer.
*/
export default class BinaryDecoder { export default class BinaryDecoder {
/**
* @param {Uint8Array|Buffer} buffer The binary data that this instance
* decodes.
*/
constructor (buffer) { constructor (buffer) {
if (buffer instanceof ArrayBuffer) { if (buffer instanceof ArrayBuffer) {
this.uint8arr = new Uint8Array(buffer) this.uint8arr = new Uint8Array(buffer)
} else if (buffer instanceof Uint8Array || (typeof Buffer !== 'undefined' && buffer instanceof Buffer)) { } else if (
buffer instanceof Uint8Array ||
(
typeof Buffer !== 'undefined' && buffer instanceof Buffer
)
) {
this.uint8arr = buffer this.uint8arr = buffer
} else { } else {
throw new Error('Expected an ArrayBuffer or Uint8Array!') throw new Error('Expected an ArrayBuffer or Uint8Array!')
} }
this.pos = 0 this.pos = 0
} }
/** /**
* Clone this decoder instance * Clone this decoder instance.
* Optionally set a new position parameter * Optionally set a new position parameter.
*/ */
clone (newPos = this.pos) { clone (newPos = this.pos) {
let decoder = new BinaryDecoder(this.uint8arr) let decoder = new BinaryDecoder(this.uint8arr)
decoder.pos = newPos decoder.pos = newPos
return decoder return decoder
} }
/** /**
* Number of bytes * Number of bytes.
*/ */
get length () { get length () {
return this.uint8arr.length return this.uint8arr.length
} }
/** /**
* Skip one byte, jump to the next position * Skip one byte, jump to the next position.
*/ */
skip8 () { skip8 () {
this.pos++ this.pos++
} }
/** /**
* Read one byte as unsigned integer * Read one byte as unsigned integer.
*/ */
readUint8 () { readUint8 () {
return this.uint8arr[this.pos++] return this.uint8arr[this.pos++]
} }
/** /**
* Read 4 bytes as unsigned integer * Read 4 bytes as unsigned integer.
*
* @return {number} An unsigned integer.
*/ */
readUint32 () { readUint32 () {
let uint = let uint =
@ -51,19 +70,24 @@ export default class BinaryDecoder {
this.pos += 4 this.pos += 4
return uint return uint
} }
/** /**
* Look ahead without incrementing position * Look ahead without incrementing position.
* to the next byte and read it as unsigned integer * to the next byte and read it as unsigned integer.
*
* @return {number} An unsigned integer.
*/ */
peekUint8 () { peekUint8 () {
return this.uint8arr[this.pos] return this.uint8arr[this.pos]
} }
/** /**
* Read unsigned integer (32bit) with variable length * Read unsigned integer (32bit) with variable length.
* 1/8th of the storage is used as encoding overhead * 1/8th of the storage is used as encoding overhead.
* - numbers < 2^7 is stored in one byte * * numbers < 2^7 is stored in one byte.
* - numbers < 2^14 is stored in two bytes * * numbers < 2^14 is stored in two bytes.
* .. *
* @return {number} An unsigned integer.
*/ */
readVarUint () { readVarUint () {
let num = 0 let num = 0
@ -80,9 +104,12 @@ export default class BinaryDecoder {
} }
} }
} }
/** /**
* Read string of variable length * Read string of variable length
* - varUint is used to store the length of the string * * varUint is used to store the length of the string
*
* @return {String} The read String.
*/ */
readVarString () { readVarString () {
let len = this.readVarUint() let len = this.readVarUint()
@ -90,9 +117,10 @@ export default class BinaryDecoder {
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
bytes[i] = this.uint8arr[this.pos++] bytes[i] = this.uint8arr[this.pos++]
} }
let encodedString = String.fromCodePoint(...bytes) let encodedString = bytes.map(b => String.fromCodePoint(b)).join('')
return decodeURIComponent(escape(encodedString)) return decodeURIComponent(escape(encodedString))
} }
/** /**
* Look ahead and read varString without incrementing position * Look ahead and read varString without incrementing position
*/ */
@ -102,10 +130,13 @@ export default class BinaryDecoder {
this.pos = pos this.pos = pos
return s return s
} }
/** /**
* Read ID * Read ID.
* - If first varUint read is 0xFFFFFF a RootID is returned * * If first varUint read is 0xFFFFFF a RootID is returned.
* - Otherwise an ID is returned * * Otherwise an ID is returned.
*
* @return ID
*/ */
readID () { readID () {
let user = this.readVarUint() let user = this.readVarUint()

145
src/Util/Binary/Encoder.js Normal file
View File

@ -0,0 +1,145 @@
import { RootFakeUserID } from '../ID/RootID.js'
const bits7 = 0b1111111
const bits8 = 0b11111111
/**
* A BinaryEncoder handles the encoding to an ArrayBuffer.
*/
export default class BinaryEncoder {
constructor () {
// TODO: implement chained Uint8Array buffers instead of Array buffer
// TODO: Rewrite all methods as functions!
this.data = []
}
/**
* The current length of the encoded data.
*/
get length () {
return this.data.length
}
/**
* The current write pointer (the same as {@link length}).
*/
get pos () {
return this.data.length
}
/**
* Create an ArrayBuffer.
*
* @return {Uint8Array} A Uint8Array that represents the written data.
*/
createBuffer () {
return Uint8Array.from(this.data).buffer
}
/**
* Write one byte as an unsigned integer.
*
* @param {number} num The number that is to be encoded.
*/
writeUint8 (num) {
this.data.push(num & bits8)
}
/**
* Write one byte as an unsigned Integer at a specific location.
*
* @param {number} pos The location where the data will be written.
* @param {number} num The number that is to be encoded.
*/
setUint8 (pos, num) {
this.data[pos] = num & bits8
}
/**
* Write two bytes as an unsigned integer.
*
* @param {number} num The number that is to be encoded.
*/
writeUint16 (num) {
this.data.push(num & bits8, (num >>> 8) & bits8)
}
/**
* Write two bytes as an unsigned integer at a specific location.
*
* @param {number} pos The location where the data will be written.
* @param {number} num The number that is to be encoded.
*/
setUint16 (pos, num) {
this.data[pos] = num & bits8
this.data[pos + 1] = (num >>> 8) & bits8
}
/**
* Write two bytes as an unsigned integer
*
* @param {number} num The number that is to be encoded.
*/
writeUint32 (num) {
for (let i = 0; i < 4; i++) {
this.data.push(num & bits8)
num >>>= 8
}
}
/**
* Write two bytes as an unsigned integer at a specific location.
*
* @param {number} pos The location where the data will be written.
* @param {number} num The number that is to be encoded.
*/
setUint32 (pos, num) {
for (let i = 0; i < 4; i++) {
this.data[pos + i] = num & bits8
num >>>= 8
}
}
/**
* Write a variable length unsigned integer.
*
* @param {number} num The number that is to be encoded.
*/
writeVarUint (num) {
while (num >= 0b10000000) {
this.data.push(0b10000000 | (bits7 & num))
num >>>= 7
}
this.data.push(bits7 & num)
}
/**
* Write a variable length string.
*
* @param {String} str The string that is to be encoded.
*/
writeVarString (str) {
let encodedString = unescape(encodeURIComponent(str))
let bytes = encodedString.split('').map(c => c.codePointAt())
let len = bytes.length
this.writeVarUint(len)
for (let i = 0; i < len; i++) {
this.data.push(bytes[i])
}
}
/**
* Write an ID at the current position.
*
* @param {ID} id The ID that is to be written.
*/
writeID (id) {
const user = id.user
this.writeVarUint(user)
if (user !== RootFakeUserID) {
this.writeVarUint(id.clock)
} else {
this.writeVarString(id.name)
this.writeVarUint(id.type)
}
}
}

View File

@ -1,22 +1,56 @@
/**
* General event handler implementation.
*/
export default class EventHandler { export default class EventHandler {
constructor () { constructor () {
this.eventListeners = [] this.eventListeners = []
} }
/**
* To prevent memory leaks, call this method when the eventListeners won't be
* used anymore.
*/
destroy () { destroy () {
this.eventListeners = null this.eventListeners = null
} }
/**
* Adds an event listener that is called when
* {@link EventHandler#callEventListeners} is called.
*
* @param {Function} f The event handler.
*/
addEventListener (f) { addEventListener (f) {
this.eventListeners.push(f) this.eventListeners.push(f)
} }
/**
* Removes an event listener.
*
* @param {Function} f The event handler that was added with
* {@link EventHandler#addEventListener}
*/
removeEventListener (f) { removeEventListener (f) {
this.eventListeners = this.eventListeners.filter(function (g) { this.eventListeners = this.eventListeners.filter(function (g) {
return f !== g return f !== g
}) })
} }
/**
* Removes all event listeners.
*/
removeAllEventListeners () { removeAllEventListeners () {
this.eventListeners = [] this.eventListeners = []
} }
/**
* Call all event listeners that were added via
* {@link EventHandler#addEventListener}.
*
* @param {Transaction} transaction The transaction object // TODO: do we need this?
* @param {YEvent} event An event object that describes the change on a type.
*/
callEventListeners (transaction, event) { callEventListeners (transaction, event) {
for (var i = 0; i < this.eventListeners.length; i++) { for (var i = 0; i < this.eventListeners.length; i++) {
try { try {

View File

@ -1,7 +1,7 @@
export default class ID { export default class ID {
constructor (user, clock) { constructor (user, clock) {
this.user = user this.user = user // TODO: rename to client
this.clock = clock this.clock = clock
} }
clone () { clone () {

View File

@ -1,4 +1,4 @@
import { getReference } from './structReferences.js' import { getStructReference } from '../structReferences.js'
export const RootFakeUserID = 0xFFFFFF export const RootFakeUserID = 0xFFFFFF
@ -6,7 +6,7 @@ export default class RootID {
constructor (name, typeConstructor) { constructor (name, typeConstructor) {
this.user = RootFakeUserID this.user = RootFakeUserID
this.name = name this.name = name
this.type = getReference(typeConstructor) this.type = getStructReference(typeConstructor)
} }
equals (id) { equals (id) {
return id !== null && id.user === this.user && id.name === this.name && id.type === this.type return id !== null && id.user === this.user && id.name === this.name && id.type === this.type

View File

@ -1,8 +1,19 @@
/**
* Handles named events.
*/
export default class NamedEventHandler { export default class NamedEventHandler {
constructor () { constructor () {
this._eventListener = new Map() this._eventListener = new Map()
this._stateListener = new Map() this._stateListener = new Map()
} }
/**
* @private
* Returns all listeners that listen to a specified name.
*
* @param {String} name The query event name.
*/
_getListener (name) { _getListener (name) {
let listeners = this._eventListener.get(name) let listeners = this._eventListener.get(name)
if (listeners === undefined) { if (listeners === undefined) {
@ -14,14 +25,34 @@ export default class NamedEventHandler {
} }
return listeners return listeners
} }
/**
* Adds a named event listener. The listener is removed after it has been
* called once.
*
* @param {String} name The event name to listen to.
* @param {Function} f The function that is executed when the event is fired.
*/
once (name, f) { once (name, f) {
let listeners = this._getListener(name) let listeners = this._getListener(name)
listeners.once.add(f) listeners.once.add(f)
} }
/**
* Adds a named event listener.
*
* @param {String} name The event name to listen to.
* @param {Function} f The function that is executed when the event is fired.
*/
on (name, f) { on (name, f) {
let listeners = this._getListener(name) let listeners = this._getListener(name)
listeners.on.add(f) listeners.on.add(f)
} }
/**
* @private
* Init the saved state for an event name.
*/
_initStateListener (name) { _initStateListener (name) {
let state = this._stateListener.get(name) let state = this._stateListener.get(name)
if (state === undefined) { if (state === undefined) {
@ -33,9 +64,20 @@ export default class NamedEventHandler {
} }
return state return state
} }
/**
* Returns a Promise that is resolved when the event name is called.
* The Promise is immediately resolved when the event name was called in the
* past.
*/
when (name) { when (name) {
return this._initStateListener(name).promise return this._initStateListener(name).promise
} }
/**
* Remove an event listener that was registered with either
* {@link EventHandler#on} or {@link EventHandler#once}.
*/
off (name, f) { off (name, f) {
if (name == null || f == null) { if (name == null || f == null) {
throw new Error('You must specify event name and function!') throw new Error('You must specify event name and function!')
@ -46,6 +88,14 @@ export default class NamedEventHandler {
listener.once.delete(f) listener.once.delete(f)
} }
} }
/**
* Emit a named event. All registered event listeners that listen to the
* specified name will receive the event.
*
* @param {String} name The event name.
* @param {Array} args The arguments that are applied to the event listener.
*/
emit (name, ...args) { emit (name, ...args) {
this._initStateListener(name).resolve() this._initStateListener(name).resolve()
const listener = this._eventListener.get(name) const listener = this._eventListener.get(name)

View File

@ -1,4 +1,17 @@
function rotate (tree, parent, newParent, n) {
if (parent === null) {
tree.root = newParent
newParent._parent = null
} else if (parent.left === n) {
parent.left = newParent
} else if (parent.right === n) {
parent.right = newParent
} else {
throw new Error('The elements are wrongly connected!')
}
}
class N { class N {
// A created node is always red! // A created node is always red!
constructor (val) { constructor (val) {
@ -41,21 +54,12 @@ class N {
this._right = n this._right = n
} }
rotateLeft (tree) { rotateLeft (tree) {
var parent = this.parent const parent = this.parent
var newParent = this.right const newParent = this.right
var newRight = this.right.left const newRight = this.right.left
newParent.left = this newParent.left = this
this.right = newRight this.right = newRight
if (parent === null) { rotate(tree, parent, newParent, this)
tree.root = newParent
newParent._parent = null
} else if (parent.left === this) {
parent.left = newParent
} else if (parent.right === this) {
parent.right = newParent
} else {
throw new Error('The elements are wrongly connected!')
}
} }
next () { next () {
if (this.right !== null) { if (this.right !== null) {
@ -90,21 +94,12 @@ class N {
} }
} }
rotateRight (tree) { rotateRight (tree) {
var parent = this.parent const parent = this.parent
var newParent = this.left const newParent = this.left
var newLeft = this.left.right const newLeft = this.left.right
newParent.right = this newParent.right = this
this.left = newLeft this.left = newLeft
if (parent === null) { rotate(tree, parent, newParent, this)
tree.root = newParent
newParent._parent = null
} else if (parent.left === this) {
parent.left = newParent
} else if (parent.right === this) {
parent.right = newParent
} else {
throw new Error('The elements are wrongly connected!')
}
} }
getUncle () { getUncle () {
// we can assume that grandparent exists when this is called! // we can assume that grandparent exists when this is called!
@ -467,5 +462,4 @@ export default class Tree {
} }
} }
} }
flush () {}
} }

View File

@ -1,4 +1,5 @@
import ID from './ID.js' import ID from './ID/ID.js'
import isParentOf from './isParentOf.js'
class ReverseOperation { class ReverseOperation {
constructor (y, transaction) { constructor (y, transaction) {
@ -15,16 +16,6 @@ class ReverseOperation {
} }
} }
function isStructInScope (y, struct, scope) {
while (struct !== y) {
if (struct === scope) {
return true
}
struct = struct._parent
}
return false
}
function applyReverseOperation (y, scope, reverseBuffer) { function applyReverseOperation (y, scope, reverseBuffer) {
let performedUndo = false let performedUndo = false
y.transact(() => { y.transact(() => {
@ -38,7 +29,7 @@ function applyReverseOperation (y, scope, reverseBuffer) {
while (op._deleted && op._redone !== null) { while (op._deleted && op._redone !== null) {
op = op._redone op = op._redone
} }
if (op._deleted === false && isStructInScope(y, op, scope)) { if (op._deleted === false && isParentOf(scope, op)) {
performedUndo = true performedUndo = true
op._delete(y) op._delete(y)
} }
@ -46,7 +37,7 @@ function applyReverseOperation (y, scope, reverseBuffer) {
} }
for (let op of undoOp.deletedStructs) { for (let op of undoOp.deletedStructs) {
if ( if (
isStructInScope(y, op, scope) && isParentOf(scope, op) &&
op._parent !== y && op._parent !== y &&
( (
op._id.user !== y.userID || op._id.user !== y.userID ||
@ -64,7 +55,15 @@ function applyReverseOperation (y, scope, reverseBuffer) {
return performedUndo return performedUndo
} }
/**
* Saves a history of locally applied operations. The UndoManager handles the
* undoing and redoing of locally created changes.
*/
export default class UndoManager { export default class UndoManager {
/**
* @param {YType} scope The scope on which to listen for changes.
* @param {Object} options Optionally provided configuration.
*/
constructor (scope, options = {}) { constructor (scope, options = {}) {
this.options = options this.options = options
options.captureTimeout = options.captureTimeout == null ? 500 : options.captureTimeout options.captureTimeout = options.captureTimeout == null ? 500 : options.captureTimeout
@ -76,6 +75,7 @@ export default class UndoManager {
this._lastTransactionWasUndo = false this._lastTransactionWasUndo = false
const y = scope._y const y = scope._y
this.y = y this.y = y
y._hasUndoManager = true
y.on('afterTransaction', (y, transaction, remote) => { y.on('afterTransaction', (y, transaction, remote) => {
if (!remote && transaction.changedParentTypes.has(scope)) { if (!remote && transaction.changedParentTypes.has(scope)) {
let reverseOperation = new ReverseOperation(y, transaction) let reverseOperation = new ReverseOperation(y, transaction)
@ -109,12 +109,20 @@ export default class UndoManager {
} }
}) })
} }
/**
* Undo the last locally created change.
*/
undo () { undo () {
this._undoing = true this._undoing = true
const performedUndo = applyReverseOperation(this.y, this._scope, this._undoBuffer) const performedUndo = applyReverseOperation(this.y, this._scope, this._undoBuffer)
this._undoing = false this._undoing = false
return performedUndo return performedUndo
} }
/**
* Redo the last locally created change.
*/
redo () { redo () {
this._redoing = true this._redoing = true
const performedRedo = applyReverseOperation(this.y, this._scope, this._redoBuffer) const performedRedo = applyReverseOperation(this.y, this._scope, this._redoBuffer)

View File

@ -1,28 +1,36 @@
/**
* YEvent describes the changes on a YType.
*/
export default class YEvent { export default class YEvent {
/**
* @param {YType} target The changed type.
*/
constructor (target) { constructor (target) {
/**
* The type on which this event was created on.
* @type {YType}
*/
this.target = target this.target = target
/**
* The current target on which the observe callback is called.
* @type {YType}
*/
this.currentTarget = target this.currentTarget = target
} }
/**
* Computes the path from `y` to the changed type.
*
* The following property holds:
* @example
* let type = y
* event.path.forEach(function (dir) {
* type = type.get(dir)
* })
* type === event.target // => true
*/
get path () { get path () {
const path = [] return this.currentTarget.getPathTo(this.target)
let type = this.target
const y = type._y
while (type !== this.currentTarget && type !== y) {
let parent = type._parent
if (type._parentSub !== null) {
path.unshift(type._parentSub)
} else {
// parent is array-ish
for (let [i, child] of parent) {
if (child === type) {
path.unshift(i)
break
}
}
}
type = parent
}
return path
} }
} }

View File

@ -1,5 +1,5 @@
import ID from '../Util/ID.js' import ID from '../Util/ID/ID.js'
import ItemJSON from '../Struct/ItemJSON.js' import ItemJSON from '../Struct/ItemJSON.js'
import ItemString from '../Struct/ItemString.js' import ItemString from '../Struct/ItemString.js'

View File

@ -1,6 +1,6 @@
/* global crypto */ /* global crypto */
export function generateUserID () { export function generateRandomUint32 () {
if (typeof crypto !== 'undefined' && crypto.getRandomValue != null) { if (typeof crypto !== 'undefined' && crypto.getRandomValue != null) {
// browser // browser
let arr = new Uint32Array(1) let arr = new Uint32Array(1)

20
src/Util/isParentOf.js Normal file
View File

@ -0,0 +1,20 @@
/**
* Check if `parent` is a parent of `child`.
*
* @param {Type} parent
* @param {Type} child
* @return {Boolean} Whether `parent` is a parent of `child`.
*
* @public
*/
export default function isParentOf (parent, child) {
child = child._parent
while (child !== null) {
if (child === parent) {
return true
}
child = child._parent
}
return false
}

View File

@ -1,4 +1,22 @@
// TODO: rename mutex
/**
* Creates a mutual exclude function with the following property:
*
* @example
* const mutualExclude = createMutualExclude()
* mutualExclude(function () {
* // This function is immediately executed
* mutualExclude(function () {
* // This function is never executed, as it is called with the same
* // mutualExclude
* })
* })
*
* @return {Function} A mutual exclude function
* @public
*/
export function createMutualExclude () { export function createMutualExclude () {
var token = true var token = true
return function mutualExclude (f) { return function mutualExclude (f) {

View File

@ -1,7 +1,46 @@
import ID from './ID.js' import ID from './ID/ID.js'
import RootID from './RootID.js' import RootID from './ID/RootID.js'
import GC from '../Struct/GC.js'
// TODO: Implement function to describe ranges
/**
* A relative position that is based on the Yjs model. In contrast to an
* absolute position (position by index), the relative position can be
* recomputed when remote changes are received. For example:
*
* ```Insert(0, 'x')('a|bc') = 'xa|bc'``` Where | is the cursor position.
*
* A relative cursor position can be obtained with the function
* {@link getRelativePosition} and it can be transformed to an absolute position
* with {@link fromRelativePosition}.
*
* Pro tip: Use this to implement shared cursor locations in YText or YXml!
* The relative position is {@link encodable}, so you can send it to other
* clients.
*
* @example
* // Current cursor position is at position 10
* let relativePosition = getRelativePosition(yText, 10)
* // modify yText
* yText.insert(0, 'abc')
* yText.delete(3, 10)
* // Compute the cursor position
* let absolutePosition = fromRelativePosition(y, relativePosition)
* absolutePosition.type // => yText
* console.log('cursor location is ' + absolutePosition.offset) // => cursor location is 3
*
* @typedef {encodable} RelativePosition
*/
/**
* Create a relativePosition based on a absolute position.
*
* @param {YType} type The base type (e.g. YText or YArray).
* @param {Integer} offset The absolute position.
*/
export function getRelativePosition (type, offset) { export function getRelativePosition (type, offset) {
// TODO: rename to createRelativePosition
let t = type._start let t = type._start
while (t !== null) { while (t !== null) {
if (t._deleted === false) { if (t._deleted === false) {
@ -15,6 +54,20 @@ export function getRelativePosition (type, offset) {
return ['endof', type._id.user, type._id.clock || null, type._id.name || null, type._id.type || null] return ['endof', type._id.user, type._id.clock || null, type._id.name || null, type._id.type || null]
} }
/**
* @typedef {Object} AbsolutePosition The result of {@link fromRelativePosition}
* @property {YType} type The type on which to apply the absolute position.
* @property {Integer} offset The absolute offset.r
*/
/**
* Transforms a relative position back to a relative position.
*
* @param {Y} y The Yjs instance in which to query for the absolute position.
* @param {RelativePosition} rpos The relative position.
* @return {AbsolutePosition} The absolute position in the Yjs model
* (type + offset).
*/
export function fromRelativePosition (y, rpos) { export function fromRelativePosition (y, rpos) {
if (rpos[0] === 'endof') { if (rpos[0] === 'endof') {
let id let id
@ -24,6 +77,9 @@ export function fromRelativePosition (y, rpos) {
id = new RootID(rpos[3], rpos[4]) id = new RootID(rpos[3], rpos[4])
} }
const type = y.os.get(id) const type = y.os.get(id)
if (type === null || type.constructor === GC) {
return null
}
return { return {
type, type,
offset: type.length offset: type.length
@ -32,7 +88,7 @@ export function fromRelativePosition (y, rpos) {
let offset = 0 let offset = 0
let struct = y.os.findNodeWithUpperBound(new ID(rpos[0], rpos[1])).val let struct = y.os.findNodeWithUpperBound(new ID(rpos[0], rpos[1])).val
const parent = struct._parent const parent = struct._parent
if (parent._deleted) { if (struct.constructor === GC || parent._deleted) {
return null return null
} }
if (!struct._deleted) { if (!struct._deleted) {

View File

@ -1,4 +1,32 @@
/**
* A SimpleDiff describes a change on a String.
*
* @example
* console.log(a) // the old value
* console.log(b) // the updated value
* // Apply changes of diff (pseudocode)
* a.remove(diff.pos, diff.remove) // Remove `diff.remove` characters
* a.insert(diff.pos, diff.insert) // Insert `diff.insert`
* a === b // values match
*
* @typedef {Object} SimpleDiff
* @property {Number} pos The index where changes were applied
* @property {Number} delete The number of characters to delete starting
* at `index`.
* @property {String} insert The new text to insert at `index` after applying
* `delete`
*/
/**
* Create a diff between two strings. This diff implementation is highly
* efficient, but not very sophisticated.
*
* @public
* @param {String} a The old version of the string
* @param {String} b The updated version of the string
* @return {SimpleDiff} The diff description.
*/
export default function simpleDiff (a, b) { export default function simpleDiff (a, b) {
let left = 0 // number of same characters counting from left let left = 0 // number of same characters counting from left
let right = 0 // number of same characters counting from right let right = 0 // number of same characters counting from right
@ -12,7 +40,7 @@ export default function simpleDiff (a, b) {
} }
} }
return { return {
pos: left, pos: left, // TODO: rename to index (also in type above)
remove: a.length - left - right, remove: a.length - left - right,
insert: b.slice(left, b.length - right) insert: b.slice(left, b.length - right)
} }

View File

@ -1,36 +1,59 @@
import YArray from '../Type/YArray.js' import YArray from '../Types/YArray/YArray.js'
import YMap from '../Type/YMap.js' import YMap from '../Types/YMap/YMap.js'
import YText from '../Type/YText.js' import YText from '../Types/YText/YText.js'
import { YXmlFragment, YXmlElement, YXmlText, YXmlHook } from '../Type/y-xml/y-xml.js' import { YXmlFragment, YXmlElement, YXmlText, YXmlHook } from '../Types/YXml/YXml.js'
import Delete from '../Struct/Delete.js' import Delete from '../Struct/Delete.js'
import ItemJSON from '../Struct/ItemJSON.js' import ItemJSON from '../Struct/ItemJSON.js'
import ItemString from '../Struct/ItemString.js' import ItemString from '../Struct/ItemString.js'
import ItemFormat from '../Struct/ItemFormat.js'
import ItemEmbed from '../Struct/ItemEmbed.js'
import GC from '../Struct/GC.js'
const structs = new Map() const structs = new Map()
const references = new Map() const references = new Map()
export function addStruct (reference, structConstructor) { /**
* Register a new Yjs types. The same type must be defined with the same
* reference on all clients!
*
* @param {Number} reference
* @param {class} structConstructor
*
* @public
*/
export function registerStruct (reference, structConstructor) {
structs.set(reference, structConstructor) structs.set(reference, structConstructor)
references.set(structConstructor, reference) references.set(structConstructor, reference)
} }
/**
* @private
*/
export function getStruct (reference) { export function getStruct (reference) {
return structs.get(reference) return structs.get(reference)
} }
export function getReference (typeConstructor) { /**
* @private
*/
export function getStructReference (typeConstructor) {
return references.get(typeConstructor) return references.get(typeConstructor)
} }
addStruct(0, ItemJSON) // TODO: reorder (Item* should have low numbers)
addStruct(1, ItemString) registerStruct(0, ItemJSON)
addStruct(2, Delete) registerStruct(1, ItemString)
registerStruct(10, ItemFormat)
registerStruct(11, ItemEmbed)
registerStruct(2, Delete)
addStruct(3, YArray) registerStruct(3, YArray)
addStruct(4, YMap) registerStruct(4, YMap)
addStruct(5, YText) registerStruct(5, YText)
addStruct(6, YXmlFragment) registerStruct(6, YXmlFragment)
addStruct(7, YXmlElement) registerStruct(7, YXmlElement)
addStruct(8, YXmlText) registerStruct(8, YXmlText)
addStruct(9, YXmlHook) registerStruct(9, YXmlHook)
registerStruct(12, GC)

View File

@ -1,33 +0,0 @@
import YMap from '../Type/YMap'
import YArray from '../Type/YArray'
export function writeObjectToYMap (object, type) {
for (var key in object) {
var val = object[key]
if (Array.isArray(val)) {
type.set(key, YArray)
writeArrayToYArray(val, type.get(key))
} else if (typeof val === 'object') {
type.set(key, YMap)
writeObjectToYMap(val, type.get(key))
} else {
type.set(key, val)
}
}
}
export function writeArrayToYArray (array, type) {
for (var i = array.length - 1; i >= 0; i--) {
var val = array[i]
if (Array.isArray(val)) {
type.insert(0, [YArray])
writeArrayToYArray(val, type.get(0))
} else if (typeof val === 'object') {
type.insert(0, [YMap])
writeObjectToYMap(val, type.get(0))
} else {
type.insert(0, [val])
}
}
}

59
src/Y.dist.js Normal file
View File

@ -0,0 +1,59 @@
import Y from './Y.js'
import UndoManager from './Util/UndoManager.js'
import { integrateRemoteStructs } from './MessageHandler/integrateRemoteStructs.js'
import { messageToString, messageToRoomname } from './MessageHandler/messageToString.js'
import Connector from './Connector.js'
import Persistence from './Persistence.js'
import YArray from './Types/YArray/YArray.js'
import YMap from './Types/YMap/YMap.js'
import YText from './Types/YText/YText.js'
import { YXmlFragment, YXmlElement, YXmlText, YXmlHook } from './Types/YXml/YXml.js'
import BinaryDecoder from './Util/Binary/Decoder.js'
import { getRelativePosition, fromRelativePosition } from './Util/relativePosition.js'
import { registerStruct } from './Util/structReferences.js'
import TextareaBinding from './Bindings/TextareaBinding/TextareaBinding.js'
import QuillBinding from './Bindings/QuillBinding/QuillBinding.js'
import DomBinding from './Bindings/DomBinding/DomBinding.js'
import { toBinary, fromBinary } from './MessageHandler/binaryEncode.js'
import debug from 'debug'
import domToType from './Bindings/DomBinding/domToType.js'
import { domsToTypes, switchAssociation } from './Bindings/DomBinding/util.js'
// TODO: The following assignments should be moved to yjs-dist
Y.AbstractConnector = Connector
Y.AbstractPersistence = Persistence
Y.Array = YArray
Y.Map = YMap
Y.Text = YText
Y.XmlElement = YXmlElement
Y.XmlFragment = YXmlFragment
Y.XmlText = YXmlText
Y.XmlHook = YXmlHook
Y.TextareaBinding = TextareaBinding
Y.QuillBinding = QuillBinding
Y.DomBinding = DomBinding
DomBinding.domToType = domToType
DomBinding.domsToTypes = domsToTypes
DomBinding.switchAssociation = switchAssociation
Y.utils = {
BinaryDecoder,
UndoManager,
getRelativePosition,
fromRelativePosition,
registerStruct,
integrateRemoteStructs,
toBinary,
fromBinary
}
Y.debug = debug
debug.formatters.Y = messageToString
debug.formatters.y = messageToRoomname
export default Y

178
src/Y.js
View File

@ -1,41 +1,51 @@
import DeleteStore from './Store/DeleteStore.js' import DeleteStore from './Store/DeleteStore.js'
import OperationStore from './Store/OperationStore.js' import OperationStore from './Store/OperationStore.js'
import StateStore from './Store/StateStore.js' import StateStore from './Store/StateStore.js'
import { generateUserID } from './Util/generateUserID.js' import { generateRandomUint32 } from './Util/generateRandomUint32.js'
import RootID from './Util/RootID.js' import RootID from './Util/ID/RootID.js'
import NamedEventHandler from './Util/NamedEventHandler.js' import NamedEventHandler from './Util/NamedEventHandler.js'
import UndoManager from './Util/UndoManager.js'
import { integrateRemoteStructs } from './MessageHandler/integrateRemoteStructs.js'
import { messageToString, messageToRoomname } from './MessageHandler/messageToString.js'
import Connector from './Connector.js'
import Persistence from './Persistence.js'
import YArray from './Type/YArray.js'
import YMap from './Type/YMap.js'
import YText from './Type/YText.js'
import { YXmlFragment, YXmlElement, YXmlText, YXmlHook } from './Type/y-xml/y-xml.js'
import BinaryDecoder from './Binary/Decoder.js'
import { getRelativePosition, fromRelativePosition } from './Util/relativePosition.js'
import { addStruct as addType } from './Util/structReferences.js'
import debug from 'debug'
import Transaction from './Transaction.js' import Transaction from './Transaction.js'
import TextareaBinding from './Binding/TextareaBinding.js' export { default as DomBinding } from './Bindings/DomBinding/DomBinding.js'
import { toBinary, fromBinary } from './MessageHandler/binaryEncode.js' /**
* Anything that can be encoded with `JSON.stringify` and can be decoded with
* `JSON.parse`.
*
* The following property should hold:
* `JSON.parse(JSON.stringify(key))===key`
*
* At the moment the only safe values are number and string.
*
* @typedef {(number|string)} encodable
*/
/**
* A Yjs instance handles the state of shared data.
*
* @param {string} room Users in the same room share the same content
* @param {Object} opts Connector definition
* @param {AbstractPersistence} persistence Persistence adapter instance
*/
export default class Y extends NamedEventHandler { export default class Y extends NamedEventHandler {
constructor (room, opts, persistence) { constructor (room, opts, persistence) {
super() super()
/**
* The room name that this Yjs instance connects to.
* @type {String}
*/
this.room = room this.room = room
if (opts != null) { if (opts != null) {
opts.connector.room = room opts.connector.room = room
} }
this._contentReady = false this._contentReady = false
this._opts = opts this._opts = opts
this.userID = generateUserID() if (typeof opts.userID !== 'number') {
this.userID = generateRandomUint32()
} else {
this.userID = opts.userID
}
// TODO: This should be a Map so we can use encodables as keys
this.share = {} this.share = {}
this.ds = new DeleteStore(this) this.ds = new DeleteStore(this)
this.os = new OperationStore(this) this.os = new OperationStore(this)
@ -43,6 +53,10 @@ export default class Y extends NamedEventHandler {
this._missingStructs = new Map() this._missingStructs = new Map()
this._readyToIntegrate = [] this._readyToIntegrate = []
this._transaction = null this._transaction = null
/**
* The {@link AbstractConnector}.that is used by this Yjs instance.
* @type {AbstractConnector}
*/
this.connector = null this.connector = null
this.connected = false this.connected = false
let initConnection = () => { let initConnection = () => {
@ -52,13 +66,20 @@ export default class Y extends NamedEventHandler {
this.emit('connectorReady') this.emit('connectorReady')
} }
} }
/**
* The {@link AbstractPersistence} that is used by this Yjs instance.
* @type {AbstractPersistence}
*/
this.persistence = null
if (persistence != null) { if (persistence != null) {
this.persistence = persistence this.persistence = persistence
persistence._init(this).then(initConnection) persistence._init(this).then(initConnection)
} else { } else {
this.persistence = null
initConnection() initConnection()
} }
// for compatibility with isParentOf
this._parent = null
this._hasUndoManager = false
} }
_setContentReady () { _setContentReady () {
if (!this._contentReady) { if (!this._contentReady) {
@ -76,6 +97,17 @@ export default class Y extends NamedEventHandler {
} }
} }
_beforeChange () {} _beforeChange () {}
/**
* Changes that happen inside of a transaction are bundled. This means that
* the observer fires _after_ the transaction is finished and that all changes
* that happened inside of the transaction are sent as one message to the
* other peers.
*
* @param {Function} f The function that should be executed as a transaction
* @param {?Boolean} remote Optional. Whether this transaction is initiated by
* a remote peer. This should not be set manually!
* Defaults to false.
*/
transact (f, remote = false) { transact (f, remote = false) {
let initialCall = this._transaction === null let initialCall = this._transaction === null
if (initialCall) { if (initialCall) {
@ -116,13 +148,55 @@ export default class Y extends NamedEventHandler {
this.emit('afterTransaction', this, transaction, remote) this.emit('afterTransaction', this, transaction, remote)
} }
} }
// fake _start for root properties (y.set('name', type))
/**
* @private
* Fake _start for root properties (y.set('name', type))
*/
get _start () { get _start () {
return null return null
} }
/**
* @private
* Fake _start for root properties (y.set('name', type))
*/
set _start (start) { set _start (start) {
return null return null
} }
/**
* Define a shared data type.
*
* Multiple calls of `y.define(name, TypeConstructor)` yield the same result
* and do not overwrite each other. I.e.
* `y.define(name, type) === y.define(name, type)`
*
* After this method is called, the type is also available on `y.share[name]`.
*
* *Best Practices:*
* Either define all types right after the Yjs instance is created or always
* use `y.define(..)` when accessing a type.
*
* @example
* // Option 1
* const y = new Y(..)
* y.define('myArray', YArray)
* y.define('myMap', YMap)
* // .. when accessing the type use y.share[name]
* y.share.myArray.insert(..)
* y.share.myMap.set(..)
*
* // Option2
* const y = new Y(..)
* // .. when accessing the type use `y.define(..)`
* y.define('myArray', YArray).insert(..)
* y.define('myMap', YMap).set(..)
*
* @param {String} name
* @param {YType Constructor} TypeConstructor The constructor of the type definition
* @returns {YType} The created type
*/
define (name, TypeConstructor) { define (name, TypeConstructor) {
let id = new RootID(name, TypeConstructor) let id = new RootID(name, TypeConstructor)
let type = this.os.get(id) let type = this.os.get(id)
@ -133,9 +207,23 @@ export default class Y extends NamedEventHandler {
} }
return type return type
} }
/**
* Get a defined type. The type must be defined locally. First define the
* type with {@link define}.
*
* This returns the same value as `y.share[name]`
*
* @param {String} name The typename
*/
get (name) { get (name) {
return this.share[name] return this.share[name]
} }
/**
* Disconnect this Yjs Instance from the network. The connector will
* unsubscribe from the room and document updates are not shared anymore.
*/
disconnect () { disconnect () {
if (this.connected) { if (this.connected) {
this.connected = false this.connected = false
@ -144,6 +232,10 @@ export default class Y extends NamedEventHandler {
return Promise.resolve() return Promise.resolve()
} }
} }
/**
* If disconnected, tell the connector to reconnect to the room.
*/
reconnect () { reconnect () {
if (!this.connected) { if (!this.connected) {
this.connected = true this.connected = true
@ -152,6 +244,11 @@ export default class Y extends NamedEventHandler {
return Promise.resolve() return Promise.resolve()
} }
} }
/**
* Disconnect from the room, and destroy all traces of this Yjs instance.
* Persisted data will remain until removed by the persistence adapter.
*/
destroy () { destroy () {
super.destroy() super.destroy()
this.share = null this.share = null
@ -170,13 +267,6 @@ export default class Y extends NamedEventHandler {
this.ds = null this.ds = null
this.ss = null this.ss = null
} }
whenSynced () {
return new Promise(resolve => {
this.once('synced', () => {
resolve()
})
})
}
} }
Y.extend = function extendYjs () { Y.extend = function extendYjs () {
@ -189,31 +279,3 @@ Y.extend = function extendYjs () {
} }
} }
} }
// TODO: The following assignments should be moved to yjs-dist
Y.AbstractConnector = Connector
Y.AbstractPersistence = Persistence
Y.Array = YArray
Y.Map = YMap
Y.Text = YText
Y.XmlElement = YXmlElement
Y.XmlFragment = YXmlFragment
Y.XmlText = YXmlText
Y.XmlHook = YXmlHook
Y.TextareaBinding = TextareaBinding
Y.utils = {
BinaryDecoder,
UndoManager,
getRelativePosition,
fromRelativePosition,
addType,
integrateRemoteStructs,
toBinary,
fromBinary
}
Y.debug = debug
debug.formatters.Y = messageToString
debug.formatters.y = messageToRoomname

View File

@ -1,3 +0,0 @@
import Y from './Y.js'
export default Y

82
test/DeleteStore.tests.js Normal file
View File

@ -0,0 +1,82 @@
import { test } from '../node_modules/cutest/cutest.mjs'
import Chance from 'chance'
import DeleteStore from '../src/Store/DeleteStore.js'
import ID from '../src/Util/ID/ID.js'
/**
* Converts a DS to an array of length 10.
*
* @example
* const ds = new DeleteStore()
* ds.mark(new ID(0, 0), 1, false)
* ds.mark(new ID(0, 1), 1, true)
* ds.mark(new ID(0, 3), 1, false)
* dsToArray(ds) // => [0, 1, undefined, 0]
*
* @return {Array<(undefined|number)>} Array of numbers indicating if array[i] is deleted (0), garbage collected (1), or undeleted (undefined).
*/
function dsToArray (ds) {
const array = []
let i = 0
ds.iterate(null, null, function (n) {
// fill with null
while (i < n._id.clock) {
array[i++] = null
}
while (i < n._id.clock + n.len) {
array[i++] = n.gc ? 1 : 0
}
})
return array
}
test('DeleteStore', async function ds1 (t) {
const ds = new DeleteStore()
ds.mark(new ID(0, 1), 1, false)
ds.mark(new ID(0, 2), 1, false)
ds.mark(new ID(0, 3), 1, false)
t.compare(dsToArray(ds), [null, 0, 0, 0])
ds.mark(new ID(0, 2), 1, true)
t.compare(dsToArray(ds), [null, 0, 1, 0])
ds.mark(new ID(0, 1), 1, true)
t.compare(dsToArray(ds), [null, 1, 1, 0])
ds.mark(new ID(0, 3), 1, true)
t.compare(dsToArray(ds), [null, 1, 1, 1])
ds.mark(new ID(0, 5), 1, true)
ds.mark(new ID(0, 4), 1, true)
t.compare(dsToArray(ds), [null, 1, 1, 1, 1, 1])
ds.mark(new ID(0, 0), 3, false)
t.compare(dsToArray(ds), [0, 0, 0, 1, 1, 1])
})
test('random DeleteStore tests', async function randomDS (t) {
const chance = new Chance(t.getSeed() * 1000000000)
const ds = new DeleteStore()
const dsArray = []
for (let i = 0; i < 200; i++) {
const pos = chance.integer({ min: 0, max: 10 })
const len = chance.integer({ min: 0, max: 4 })
const gc = chance.bool()
ds.mark(new ID(0, pos), len, gc)
for (let j = 0; j < len; j++) {
dsArray[pos + j] = gc ? 1 : 0
}
}
// fill empty fields
for (let i = 0; i < dsArray.length; i++) {
if (dsArray[i] !== 0 && dsArray[i] !== 1) {
dsArray[i] = null
}
}
t.compare(dsToArray(ds), dsArray, 'expected DS result')
let size = 0
let lastEl = null
for (let i = 0; i < dsArray.length; i++) {
let el = dsArray[i]
if (lastEl !== el && el !== null) {
size++
}
lastEl = el
}
t.compare(size, ds.length, 'expected ds size')
})

View File

@ -1,7 +1,7 @@
import { test } from '../node_modules/cutest/cutest.mjs' import { test } from '../node_modules/cutest/cutest.mjs'
import BinaryEncoder from '../src/Binary/Encoder.js' import BinaryEncoder from '../src/Util/Binary/Encoder.js'
import BinaryDecoder from '../src/Binary/Decoder.js' import BinaryDecoder from '../src/Util/Binary/Decoder.js'
import { generateUserID } from '../src/Util/generateUserID.js' import { generateRandomUint32 } from '../src/Util/generateRandomUint32.js'
import Chance from 'chance' import Chance from 'chance'
function testEncoding (t, write, read, val) { function testEncoding (t, write, read, val) {
@ -43,7 +43,7 @@ test('varUint random', async function varUintRandom (t) {
test('varUint random user id', async function varUintRandomUserId (t) { test('varUint random user id', async function varUintRandomUserId (t) {
t.getSeed() // enforces that this test is repeated t.getSeed() // enforces that this test is repeated
testEncoding(t, writeVarUint, readVarUint, generateUserID()) testEncoding(t, writeVarUint, readVarUint, generateRandomUint32())
}) })
const writeVarString = (encoder, val) => encoder.writeVarString(val) const writeVarString = (encoder, val) => encoder.writeVarString(val)

View File

@ -3,6 +3,6 @@
<head> <head>
</head> </head>
<body> <body>
<script type="module" src="./index.js"></script> <script type="module" src="./diff.tests.js"></script>
</body> </body>
</html> </html>

View File

@ -1,5 +1,6 @@
import './red-black-tree.js' import './red-black-tree.js'
import './y-array.tests.js' import './y-array.tests.js'
import './y-text.tests.js'
import './y-map.tests.js' import './y-map.tests.js'
import './y-xml.tests.js' import './y-xml.tests.js'
import './encode-decode.tests.js' import './encode-decode.tests.js'

View File

@ -1,5 +1,5 @@
import RedBlackTree from '../src/Util/Tree.js' import RedBlackTree from '../src/Util/Tree.js'
import ID from '../src/Util/ID.js' import ID from '../src/Util/ID/ID.js'
import Chance from 'chance' import Chance from 'chance'
import { test, proxyConsole } from 'cutest' import { test, proxyConsole } from 'cutest'

102
test/y-text.tests.js Normal file
View File

@ -0,0 +1,102 @@
import { initArrays, compareUsers, flushAll } from '../tests-lib/helper.js'
import { test, proxyConsole } from 'cutest'
proxyConsole()
test('basic insert delete', async function text0 (t) {
let { users, text0 } = await initArrays(t, { users: 2 })
let delta
text0.observe(function (event) {
delta = event.delta
})
text0.delete(0, 0)
t.assert(true, 'Does not throw when deleting zero elements with position 0')
text0.insert(0, 'abc')
t.assert(text0.toString() === 'abc', 'Basic insert works')
t.compare(delta, [{ insert: 'abc' }])
text0.delete(0, 1)
t.assert(text0.toString() === 'bc', 'Basic delete works (position 0)')
t.compare(delta, [{ delete: 1 }])
text0.delete(1, 1)
t.assert(text0.toString() === 'b', 'Basic delete works (position 1)')
t.compare(delta, [{ retain: 1 }, { delete: 1 }])
await compareUsers(t, users)
})
test('basic format', async function text1 (t) {
let { users, text0 } = await initArrays(t, { users: 2 })
let delta
text0.observe(function (event) {
delta = event.delta
})
text0.insert(0, 'abc', { bold: true })
t.assert(text0.toString() === 'abc', 'Basic insert with attributes works')
t.compare(text0.toDelta(), [{ insert: 'abc', attributes: { bold: true } }])
t.compare(delta, [{ insert: 'abc', attributes: { bold: true } }])
text0.delete(0, 1)
t.assert(text0.toString() === 'bc', 'Basic delete on formatted works (position 0)')
t.compare(text0.toDelta(), [{ insert: 'bc', attributes: { bold: true } }])
t.compare(delta, [{ delete: 1 }])
text0.delete(1, 1)
t.assert(text0.toString() === 'b', 'Basic delete works (position 1)')
t.compare(text0.toDelta(), [{ insert: 'b', attributes: { bold: true } }])
t.compare(delta, [{ retain: 1 }, { delete: 1 }])
text0.insert(0, 'z', {bold: true})
t.assert(text0.toString() === 'zb')
t.compare(text0.toDelta(), [{ insert: 'zb', attributes: { bold: true } }])
t.compare(delta, [{ insert: 'z', attributes: { bold: true } }])
t.assert(text0._start._right._right._right._content === 'b', 'Does not insert duplicate attribute marker')
text0.insert(0, 'y')
t.assert(text0.toString() === 'yzb')
t.compare(text0.toDelta(), [{ insert: 'y' }, { insert: 'zb', attributes: { bold: true } }])
t.compare(delta, [{ insert: 'y' }])
text0.format(0, 2, { bold: null })
t.assert(text0.toString() === 'yzb')
t.compare(text0.toDelta(), [{ insert: 'yz' }, { insert: 'b', attributes: { bold: true } }])
t.compare(delta, [{ retain: 1 }, { retain: 1, attributes: { bold: null } }])
await compareUsers(t, users)
})
test('quill issue 1', async function quill1 (t) {
let { users, quill0 } = await initArrays(t, { users: 2 })
quill0.insertText(0, 'x')
await flushAll(t, users)
quill0.insertText(1, '\n', 'list', 'ordered')
await flushAll(t, users)
quill0.insertText(1, '\n', 'list', 'ordered')
await compareUsers(t, users)
})
test('quill issue 2', async function quill2 (t) {
let { users, quill0, text0 } = await initArrays(t, { users: 2 })
let delta
text0.observe(function (event) {
delta = event.delta
})
quill0.insertText(0, 'abc', 'bold', true)
await flushAll(t, users)
quill0.insertText(1, 'x')
quill0.update()
t.compare(delta, [{ retain: 1 }, { insert: 'x', attributes: { bold: true } }])
await compareUsers(t, users)
})
test('quill issue 3', async function quill3 (t) {
let { users, quill0, text0 } = await initArrays(t, { users: 2 })
quill0.insertText(0, 'a')
quill0.insertText(1, '\n\n', 'list', 'ordered')
quill0.insertText(2, 'b')
t.compare(text0.toDelta(), [
{ insert: 'a' },
{ insert: '\n', attributes: { list: 'ordered' } },
{ insert: 'b' },
{ insert: '\n', attributes: { list: 'ordered' } }
])
await compareUsers(t, users)
})

View File

@ -3,19 +3,17 @@ import { test } from 'cutest'
test('set property', async function xml0 (t) { test('set property', async function xml0 (t) {
var { users, xml0, xml1 } = await initArrays(t, { users: 2 }) var { users, xml0, xml1 } = await initArrays(t, { users: 2 })
xml0.setAttribute('height', 10) xml0.setAttribute('height', '10')
t.assert(xml0.getAttribute('height') === 10, 'Simple set+get works') t.assert(xml0.getAttribute('height') === '10', 'Simple set+get works')
await flushAll(t, users) await flushAll(t, users)
t.assert(xml1.getAttribute('height') === 10, 'Simple set+get works (remote)') t.assert(xml1.getAttribute('height') === '10', 'Simple set+get works (remote)')
await compareUsers(t, users) await compareUsers(t, users)
}) })
/* TODO: Test YXml events!
test('events', async function xml1 (t) { test('events', async function xml1 (t) {
var { users, xml0, xml1 } = await initArrays(t, { users: 2 }) var { users, xml0, xml1 } = await initArrays(t, { users: 2 })
var event var event
var remoteEvent var remoteEvent
let expectedEvent
xml0.observe(function (e) { xml0.observe(function (e) {
delete e._content delete e._content
delete e.nodes delete e.nodes
@ -29,48 +27,28 @@ test('events', async function xml1 (t) {
remoteEvent = e remoteEvent = e
}) })
xml0.setAttribute('key', 'value') xml0.setAttribute('key', 'value')
expectedEvent = { t.assert(event.attributesChanged.has('key'), 'YXmlEvent.attributesChanged on updated key')
type: 'attributeChanged',
value: 'value',
name: 'key'
}
t.compare(event, expectedEvent, 'attribute changed event')
await flushAll(t, users) await flushAll(t, users)
t.compare(remoteEvent, expectedEvent, 'attribute changed event (remote)') t.assert(remoteEvent.attributesChanged.has('key'), 'YXmlEvent.attributesChanged on updated key (remote)')
// check attributeRemoved // check attributeRemoved
xml0.removeAttribute('key') xml0.removeAttribute('key')
expectedEvent = { t.assert(event.attributesChanged.has('key'), 'YXmlEvent.attributesChanged on removed attribute')
type: 'attributeRemoved',
name: 'key'
}
t.compare(event, expectedEvent, 'attribute deleted event')
await flushAll(t, users) await flushAll(t, users)
t.compare(remoteEvent, expectedEvent, 'attribute deleted event (remote)') t.assert(remoteEvent.attributesChanged.has('key'), 'YXmlEvent.attributesChanged on removed attribute (remote)')
// test childInserted event
expectedEvent = {
type: 'childInserted',
index: 0
}
xml0.insert(0, [new Y.XmlText('some text')]) xml0.insert(0, [new Y.XmlText('some text')])
t.compare(event, expectedEvent, 'child inserted event') t.assert(event.childListChanged, 'YXmlEvent.childListChanged on inserted element')
await flushAll(t, users) await flushAll(t, users)
t.compare(remoteEvent, expectedEvent, 'child inserted event (remote)') t.assert(remoteEvent.childListChanged, 'YXmlEvent.childListChanged on inserted element (remote)')
// test childRemoved // test childRemoved
xml0.delete(0) xml0.delete(0)
expectedEvent = { t.assert(event.childListChanged, 'YXmlEvent.childListChanged on deleted element')
type: 'childRemoved',
index: 0
}
t.compare(event, expectedEvent, 'child deleted event')
await flushAll(t, users) await flushAll(t, users)
t.compare(remoteEvent, expectedEvent, 'child deleted event (remote)') t.assert(remoteEvent.childListChanged, 'YXmlEvent.childListChanged on deleted element (remote)')
await compareUsers(t, users) await compareUsers(t, users)
}) })
*/
test('attribute modifications (y -> dom)', async function xml2 (t) { test('attribute modifications (y -> dom)', async function xml2 (t) {
var { users, xml0 } = await initArrays(t, { users: 3 }) var { users, xml0, dom0 } = await initArrays(t, { users: 3 })
let dom0 = xml0.getDom()
xml0.setAttribute('height', '100px') xml0.setAttribute('height', '100px')
await wait() await wait()
t.assert(dom0.getAttribute('height') === '100px', 'setAttribute') t.assert(dom0.getAttribute('height') === '100px', 'setAttribute')
@ -84,8 +62,7 @@ test('attribute modifications (y -> dom)', async function xml2 (t) {
}) })
test('attribute modifications (dom -> y)', async function xml3 (t) { test('attribute modifications (dom -> y)', async function xml3 (t) {
var { users, xml0 } = await initArrays(t, { users: 3 }) var { users, xml0, dom0 } = await initArrays(t, { users: 3 })
let dom0 = xml0.getDom()
dom0.setAttribute('height', '100px') dom0.setAttribute('height', '100px')
await wait() await wait()
t.assert(xml0.getAttribute('height') === '100px', 'setAttribute') t.assert(xml0.getAttribute('height') === '100px', 'setAttribute')
@ -99,8 +76,7 @@ test('attribute modifications (dom -> y)', async function xml3 (t) {
}) })
test('element insert (dom -> y)', async function xml4 (t) { test('element insert (dom -> y)', async function xml4 (t) {
var { users, xml0 } = await initArrays(t, { users: 3 }) var { users, xml0, dom0 } = await initArrays(t, { users: 3 })
let dom0 = xml0.getDom()
dom0.insertBefore(document.createTextNode('some text'), null) dom0.insertBefore(document.createTextNode('some text'), null)
dom0.insertBefore(document.createElement('p'), null) dom0.insertBefore(document.createElement('p'), null)
await wait() await wait()
@ -110,8 +86,7 @@ test('element insert (dom -> y)', async function xml4 (t) {
}) })
test('element insert (y -> dom)', async function xml5 (t) { test('element insert (y -> dom)', async function xml5 (t) {
var { users, xml0 } = await initArrays(t, { users: 3 }) var { users, xml0, dom0 } = await initArrays(t, { users: 3 })
let dom0 = xml0.getDom()
xml0.insert(0, [new Y.XmlText('some text')]) xml0.insert(0, [new Y.XmlText('some text')])
xml0.insert(1, [new Y.XmlElement('p')]) xml0.insert(1, [new Y.XmlElement('p')])
t.assert(dom0.childNodes[0].textContent === 'some text', 'Retrieve Text node') t.assert(dom0.childNodes[0].textContent === 'some text', 'Retrieve Text node')
@ -120,8 +95,7 @@ test('element insert (y -> dom)', async function xml5 (t) {
}) })
test('y on insert, then delete (dom -> y)', async function xml6 (t) { test('y on insert, then delete (dom -> y)', async function xml6 (t) {
var { users, xml0 } = await initArrays(t, { users: 3 }) var { users, xml0, dom0 } = await initArrays(t, { users: 3 })
let dom0 = xml0.getDom()
dom0.insertBefore(document.createElement('p'), null) dom0.insertBefore(document.createElement('p'), null)
await wait() await wait()
t.assert(xml0.length === 1, 'one node present') t.assert(xml0.length === 1, 'one node present')
@ -132,8 +106,7 @@ test('y on insert, then delete (dom -> y)', async function xml6 (t) {
}) })
test('y on insert, then delete (y -> dom)', async function xml7 (t) { test('y on insert, then delete (y -> dom)', async function xml7 (t) {
var { users, xml0 } = await initArrays(t, { users: 3 }) var { users, xml0, dom0 } = await initArrays(t, { users: 3 })
let dom0 = xml0.getDom()
xml0.insert(0, [new Y.XmlElement('p')]) xml0.insert(0, [new Y.XmlElement('p')])
t.assert(dom0.childNodes[0].nodeName === 'P', 'Get inserted element from dom') t.assert(dom0.childNodes[0].nodeName === 'P', 'Get inserted element from dom')
xml0.delete(0, 1) xml0.delete(0, 1)
@ -142,8 +115,7 @@ test('y on insert, then delete (y -> dom)', async function xml7 (t) {
}) })
test('delete consecutive (1) (Text)', async function xml8 (t) { test('delete consecutive (1) (Text)', async function xml8 (t) {
var { users, xml0 } = await initArrays(t, { users: 3 }) var { users, xml0, dom0 } = await initArrays(t, { users: 3 })
let dom0 = xml0.getDom()
xml0.insert(0, [new Y.XmlText('1'), new Y.XmlText('2'), new Y.XmlText('3')]) xml0.insert(0, [new Y.XmlText('1'), new Y.XmlText('2'), new Y.XmlText('3')])
await wait() await wait()
xml0.delete(1, 2) xml0.delete(1, 2)
@ -155,8 +127,7 @@ test('delete consecutive (1) (Text)', async function xml8 (t) {
}) })
test('delete consecutive (2) (Text)', async function xml9 (t) { test('delete consecutive (2) (Text)', async function xml9 (t) {
var { users, xml0 } = await initArrays(t, { users: 3 }) var { users, xml0, dom0 } = await initArrays(t, { users: 3 })
let dom0 = xml0.getDom()
xml0.insert(0, [new Y.XmlText('1'), new Y.XmlText('2'), new Y.XmlText('3')]) xml0.insert(0, [new Y.XmlText('1'), new Y.XmlText('2'), new Y.XmlText('3')])
await wait() await wait()
xml0.delete(0, 1) xml0.delete(0, 1)
@ -169,8 +140,7 @@ test('delete consecutive (2) (Text)', async function xml9 (t) {
}) })
test('delete consecutive (1) (Element)', async function xml10 (t) { test('delete consecutive (1) (Element)', async function xml10 (t) {
var { users, xml0 } = await initArrays(t, { users: 3 }) var { users, xml0, dom0 } = await initArrays(t, { users: 3 })
let dom0 = xml0.getDom()
xml0.insert(0, [new Y.XmlElement('A'), new Y.XmlElement('B'), new Y.XmlElement('C')]) xml0.insert(0, [new Y.XmlElement('A'), new Y.XmlElement('B'), new Y.XmlElement('C')])
await wait() await wait()
xml0.delete(1, 2) xml0.delete(1, 2)
@ -182,8 +152,7 @@ test('delete consecutive (1) (Element)', async function xml10 (t) {
}) })
test('delete consecutive (2) (Element)', async function xml11 (t) { test('delete consecutive (2) (Element)', async function xml11 (t) {
var { users, xml0 } = await initArrays(t, { users: 3 }) var { users, xml0, dom0 } = await initArrays(t, { users: 3 })
let dom0 = xml0.getDom()
xml0.insert(0, [new Y.XmlElement('A'), new Y.XmlElement('B'), new Y.XmlElement('C')]) xml0.insert(0, [new Y.XmlElement('A'), new Y.XmlElement('B'), new Y.XmlElement('C')])
await wait() await wait()
xml0.delete(0, 1) xml0.delete(0, 1)
@ -196,9 +165,7 @@ test('delete consecutive (2) (Element)', async function xml11 (t) {
}) })
test('Receive a bunch of elements (with disconnect)', async function xml12 (t) { test('Receive a bunch of elements (with disconnect)', async function xml12 (t) {
var { users, xml0, xml1 } = await initArrays(t, { users: 3 }) var { users, xml0, xml1, dom0, dom1 } = await initArrays(t, { users: 3 })
let dom0 = xml0.getDom()
let dom1 = xml1.getDom()
users[1].disconnect() users[1].disconnect()
xml0.insert(0, [new Y.XmlElement('A'), new Y.XmlElement('B'), new Y.XmlElement('C')]) xml0.insert(0, [new Y.XmlElement('A'), new Y.XmlElement('B'), new Y.XmlElement('C')])
xml0.insert(0, [new Y.XmlElement('X'), new Y.XmlElement('Y'), new Y.XmlElement('Z')]) xml0.insert(0, [new Y.XmlElement('X'), new Y.XmlElement('Y'), new Y.XmlElement('Z')])
@ -212,9 +179,7 @@ test('Receive a bunch of elements (with disconnect)', async function xml12 (t) {
}) })
test('move element to a different position', async function xml13 (t) { test('move element to a different position', async function xml13 (t) {
var { users, xml0, xml1 } = await initArrays(t, { users: 3 }) var { users, dom0, dom1 } = await initArrays(t, { users: 3 })
let dom0 = xml0.getDom()
let dom1 = xml1.getDom()
dom0.append(document.createElement('div')) dom0.append(document.createElement('div'))
dom0.append(document.createElement('h1')) dom0.append(document.createElement('h1'))
await flushAll(t, users) await flushAll(t, users)
@ -227,9 +192,7 @@ test('move element to a different position', async function xml13 (t) {
}) })
test('filter node', async function xml14 (t) { test('filter node', async function xml14 (t) {
var { users, xml0, xml1 } = await initArrays(t, { users: 3 }) var { users, dom0, dom1, domBinding0, domBinding1 } = await initArrays(t, { users: 3 })
let dom0 = xml0.getDom()
let dom1 = xml1.getDom()
let domFilter = (nodeName, attrs) => { let domFilter = (nodeName, attrs) => {
if (nodeName === 'H1') { if (nodeName === 'H1') {
return null return null
@ -237,8 +200,8 @@ test('filter node', async function xml14 (t) {
return attrs return attrs
} }
} }
xml0.setDomFilter(domFilter) domBinding0.setFilter(domFilter)
xml1.setDomFilter(domFilter) domBinding1.setFilter(domFilter)
dom0.append(document.createElement('div')) dom0.append(document.createElement('div'))
dom0.append(document.createElement('h1')) dom0.append(document.createElement('h1'))
await flushAll(t, users) await flushAll(t, users)
@ -248,15 +211,13 @@ test('filter node', async function xml14 (t) {
}) })
test('filter attribute', async function xml15 (t) { test('filter attribute', async function xml15 (t) {
var { users, xml0, xml1 } = await initArrays(t, { users: 3 }) var { users, dom0, dom1, domBinding0, domBinding1 } = await initArrays(t, { users: 3 })
let dom0 = xml0.getDom()
let dom1 = xml1.getDom()
let domFilter = (nodeName, attrs) => { let domFilter = (nodeName, attrs) => {
attrs.delete('hidden') attrs.delete('hidden')
return attrs return attrs
} }
xml0.setDomFilter(domFilter) domBinding0.setFilter(domFilter)
xml1.setDomFilter(domFilter) domBinding1.setFilter(domFilter)
dom0.setAttribute('hidden', 'true') dom0.setAttribute('hidden', 'true')
dom0.setAttribute('style', 'height: 30px') dom0.setAttribute('style', 'height: 30px')
dom0.setAttribute('data-me', '77') dom0.setAttribute('data-me', '77')
@ -269,9 +230,7 @@ test('filter attribute', async function xml15 (t) {
}) })
test('deep element insert', async function xml16 (t) { test('deep element insert', async function xml16 (t) {
var { users, xml0, xml1 } = await initArrays(t, { users: 3 }) var { users, dom0, dom1 } = await initArrays(t, { users: 3 })
let dom0 = xml0.getDom()
let dom1 = xml1.getDom()
let deepElement = document.createElement('p') let deepElement = document.createElement('p')
let boldElement = document.createElement('b') let boldElement = document.createElement('b')
let attrElement = document.createElement('img') let attrElement = document.createElement('img')
@ -291,8 +250,8 @@ test('treeWalker', async function xml17 (t) {
var { users, xml0 } = await initArrays(t, { users: 3 }) var { users, xml0 } = await initArrays(t, { users: 3 })
let paragraph1 = new Y.XmlElement('p') let paragraph1 = new Y.XmlElement('p')
let paragraph2 = new Y.XmlElement('p') let paragraph2 = new Y.XmlElement('p')
let text1 = new Y.Text('init') let text1 = new Y.XmlText('init')
let text2 = new Y.Text('text') let text2 = new Y.XmlText('text')
paragraph1.insert(0, [text1, text2]) paragraph1.insert(0, [text1, text2])
xml0.insert(0, [paragraph1, paragraph2, new Y.XmlElement('img')]) xml0.insert(0, [paragraph1, paragraph2, new Y.XmlElement('img')])
let allParagraphs = xml0.querySelectorAll('p') let allParagraphs = xml0.querySelectorAll('p')
@ -309,8 +268,8 @@ test('treeWalker', async function xml17 (t) {
* Incoming changes that contain malicious attributes should be deleted. * Incoming changes that contain malicious attributes should be deleted.
*/ */
test('Filtering remote changes', async function xmlFilteringRemote (t) { test('Filtering remote changes', async function xmlFilteringRemote (t) {
var { users, xml0, xml1 } = await initArrays(t, { users: 3 }) var { users, xml0, xml1, domBinding0 } = await initArrays(t, { users: 3 })
xml0.setDomFilter(function (nodeName, attributes) { domBinding0.setFilter(function (nodeName, attributes) {
attributes.delete('malicious') attributes.delete('malicious')
if (nodeName === 'HIDEME') { if (nodeName === 'HIDEME') {
return null return null
@ -320,10 +279,6 @@ test('Filtering remote changes', async function xmlFilteringRemote (t) {
return attributes return attributes
} }
}) })
// make sure that dom filters are active
// TODO: do not rely on .getDom for domFilters
xml0.getDom()
xml1.getDom()
let paragraph = new Y.XmlElement('p') let paragraph = new Y.XmlElement('p')
let hideMe = new Y.XmlElement('hideMe') let hideMe = new Y.XmlElement('hideMe')
let span = new Y.XmlElement('span') let span = new Y.XmlElement('span')
@ -337,8 +292,8 @@ test('Filtering remote changes', async function xmlFilteringRemote (t) {
paragraph.insert(0, [tag2]) paragraph.insert(0, [tag2])
await flushAll(t, users) await flushAll(t, users)
// check dom // check dom
paragraph.getDom().setAttribute('malicious', 'true') domBinding0.typeToDom.get(paragraph).setAttribute('malicious', 'true')
span.getDom().setAttribute('malicious', 'true') domBinding0.typeToDom.get(span).setAttribute('malicious', 'true')
// check incoming attributes // check incoming attributes
xml1.get(0).get(0).setAttribute('malicious', 'true') xml1.get(0).get(0).setAttribute('malicious', 'true')
xml1.insert(0, [new Y.XmlElement('hideMe')]) xml1.insert(0, [new Y.XmlElement('hideMe')])
@ -350,35 +305,35 @@ test('Filtering remote changes', async function xmlFilteringRemote (t) {
// TODO: move elements // TODO: move elements
var xmlTransactions = [ var xmlTransactions = [
function attributeChange (t, user, chance) { function attributeChange (t, user, chance) {
user.get('xml', Y.XmlElement).getDom().setAttribute(chance.word(), chance.word()) user.dom.setAttribute(chance.word(), chance.word())
}, },
function attributeChangeHidden (t, user, chance) { function attributeChangeHidden (t, user, chance) {
user.get('xml', Y.XmlElement).getDom().setAttribute('hidden', chance.word()) user.dom.setAttribute('hidden', chance.word())
}, },
function insertText (t, user, chance) { function insertText (t, user, chance) {
let dom = user.get('xml', Y.XmlElement).getDom() let dom = user.dom
var succ = dom.children.length > 0 ? chance.pickone(dom.children) : null var succ = dom.children.length > 0 ? chance.pickone(dom.children) : null
dom.insertBefore(document.createTextNode(chance.word()), succ) dom.insertBefore(document.createTextNode(chance.word()), succ)
}, },
function insertHiddenDom (t, user, chance) { function insertHiddenDom (t, user, chance) {
let dom = user.get('xml', Y.XmlElement).getDom() let dom = user.dom
var succ = dom.children.length > 0 ? chance.pickone(dom.children) : null var succ = dom.children.length > 0 ? chance.pickone(dom.children) : null
dom.insertBefore(document.createElement('hidden'), succ) dom.insertBefore(document.createElement('hidden'), succ)
}, },
function insertDom (t, user, chance) { function insertDom (t, user, chance) {
let dom = user.get('xml', Y.XmlElement).getDom() let dom = user.dom
var succ = dom.children.length > 0 ? chance.pickone(dom.children) : null var succ = dom.children.length > 0 ? chance.pickone(dom.children) : null
dom.insertBefore(document.createElement(chance.word()), succ) dom.insertBefore(document.createElement(chance.word()), succ)
}, },
function deleteChild (t, user, chance) { function deleteChild (t, user, chance) {
let dom = user.get('xml', Y.XmlElement).getDom() let dom = user.dom
if (dom.childNodes.length > 0) { if (dom.childNodes.length > 0) {
var d = chance.pickone(dom.childNodes) var d = chance.pickone(dom.childNodes)
d.remove() d.remove()
} }
}, },
function insertTextSecondLayer (t, user, chance) { function insertTextSecondLayer (t, user, chance) {
let dom = user.get('xml', Y.XmlElement).getDom() let dom = user.dom
if (dom.children.length > 0) { if (dom.children.length > 0) {
let dom2 = chance.pickone(dom.children) let dom2 = chance.pickone(dom.children)
let succ = dom2.childNodes.length > 0 ? chance.pickone(dom2.childNodes) : null let succ = dom2.childNodes.length > 0 ? chance.pickone(dom2.childNodes) : null
@ -386,7 +341,7 @@ var xmlTransactions = [
} }
}, },
function insertDomSecondLayer (t, user, chance) { function insertDomSecondLayer (t, user, chance) {
let dom = user.get('xml', Y.XmlElement).getDom() let dom = user.dom
if (dom.children.length > 0) { if (dom.children.length > 0) {
let dom2 = chance.pickone(dom.children) let dom2 = chance.pickone(dom.children)
let succ = dom2.childNodes.length > 0 ? chance.pickone(dom2.childNodes) : null let succ = dom2.childNodes.length > 0 ? chance.pickone(dom2.childNodes) : null
@ -394,7 +349,7 @@ var xmlTransactions = [
} }
}, },
function deleteChildSecondLayer (t, user, chance) { function deleteChildSecondLayer (t, user, chance) {
let dom = user.get('xml', Y.XmlElement).getDom() let dom = user.dom
if (dom.children.length > 0) { if (dom.children.length > 0) {
let dom2 = chance.pickone(dom.children) let dom2 = chance.pickone(dom.children)
if (dom2.childNodes.length > 0) { if (dom2.childNodes.length > 0) {

View File

@ -1,19 +1,22 @@
import _Y from '../src/Y.js' import _Y from '../src/Y.dist.js'
import yTest from './test-connector.js' import { DomBinding } from '../src/Y.js'
import TestConnector from './test-connector.js'
import Chance from 'chance' import Chance from 'chance'
import ItemJSON from '../src/Struct/ItemJSON.js' import ItemJSON from '../src/Struct/ItemJSON.js'
import ItemString from '../src/Struct/ItemString.js' import ItemString from '../src/Struct/ItemString.js'
import { defragmentItemContent } from '../src/Util/defragmentItemContent.js' import { defragmentItemContent } from '../src/Util/defragmentItemContent.js'
import Quill from 'quill'
import GC from '../src/Struct/GC.js'
export const Y = _Y export const Y = _Y
Y.extend(yTest)
export const database = { name: 'memory' } export const database = { name: 'memory' }
export const connector = { name: 'test', url: 'http://localhost:1234' } export const connector = { name: 'test', url: 'http://localhost:1234' }
Y.test = TestConnector
function getStateSet (y) { function getStateSet (y) {
let ss = {} let ss = {}
for (let [user, clock] of y.ss.state) { for (let [user, clock] of y.ss.state) {
@ -39,39 +42,6 @@ function getDeleteSet (y) {
return ds return ds
} }
export function attrsObject (dom) {
let keys = []
let yxml = dom._yxml
for (let i = 0; i < dom.attributes.length; i++) {
keys.push(dom.attributes[i].name)
}
keys = yxml._domFilter(dom, keys)
let obj = {}
for (let i = 0; i < keys.length; i++) {
let key = keys[i]
obj[key] = dom.getAttribute(key)
}
return obj
}
export function domToJson (dom) {
if (dom.nodeType === document.TEXT_NODE) {
return dom.textContent
} else if (dom.nodeType === document.ELEMENT_NODE) {
let attributes = attrsObject(dom)
let children = Array.from(dom.childNodes.values())
.filter(d => d._yxml !== false)
.map(domToJson)
return {
name: dom.nodeName,
children: children,
attributes: attributes
}
} else {
throw new Error('Unsupported node type')
}
}
/* /*
* 1. reconnect and flush all * 1. reconnect and flush all
* 2. user 0 gc * 2. user 0 gc
@ -92,16 +62,29 @@ export async function compareUsers (t, users) {
await wait() await wait()
await flushAll(t, users) await flushAll(t, users)
var userArrayValues = users.map(u => u.get('array', Y.Array).toJSON().map(val => JSON.stringify(val))) var userArrayValues = users.map(u => u.define('array', Y.Array).toJSON().map(val => JSON.stringify(val)))
var userMapValues = users.map(u => u.get('map', Y.Map).toJSON()) var userMapValues = users.map(u => u.define('map', Y.Map).toJSON())
var userXmlValues = users.map(u => u.get('xml', Y.Xml).toString()) var userXmlValues = users.map(u => u.define('xml', Y.Xml).toString())
var userTextValues = users.map(u => u.define('text', Y.Text).toDelta())
var userQuillValues = users.map(u => {
u.quill.update('yjs') // get latest changes
return u.quill.getContents().ops
})
var data = users.map(u => { var data = users.map(u => {
defragmentItemContent(u) defragmentItemContent(u)
var data = {} var data = {}
let ops = [] let ops = []
u.os.iterate(null, null, function (op) { u.os.iterate(null, null, function (op) {
const json = { let json
if (op.constructor === GC) {
json = {
type: 'GC',
id: op._id,
length: op._length
}
} else {
json = {
id: op._id, id: op._id,
left: op._left === null ? null : op._left._lastId, left: op._left === null ? null : op._left._lastId,
right: op._right === null ? null : op._right._id, right: op._right === null ? null : op._right._id,
@ -109,6 +92,7 @@ export async function compareUsers (t, users) {
deleted: op._deleted, deleted: op._deleted,
parent: op._parent._id parent: op._parent._id
} }
}
if (op instanceof ItemJSON || op instanceof ItemString) { if (op instanceof ItemJSON || op instanceof ItemString) {
json.content = op._content json.content = op._content
} }
@ -124,6 +108,8 @@ export async function compareUsers (t, users) {
t.compare(userArrayValues[i], userArrayValues[i + 1], 'array types') t.compare(userArrayValues[i], userArrayValues[i + 1], 'array types')
t.compare(userMapValues[i], userMapValues[i + 1], 'map types') t.compare(userMapValues[i], userMapValues[i + 1], 'map types')
t.compare(userXmlValues[i], userXmlValues[i + 1], 'xml types') t.compare(userXmlValues[i], userXmlValues[i + 1], 'xml types')
t.compare(userTextValues[i], userTextValues[i + 1], 'text types')
t.compare(userQuillValues[i], userQuillValues[i + 1], 'quill delta content')
t.compare(data[i].os, data[i + 1].os, 'os') t.compare(data[i].os, data[i + 1].os, 'os')
t.compare(data[i].ds, data[i + 1].ds, 'ds') t.compare(data[i].ds, data[i + 1].ds, 'ds')
t.compare(data[i].ss, data[i + 1].ss, 'ss') t.compare(data[i].ss, data[i + 1].ss, 'ss')
@ -132,12 +118,20 @@ export async function compareUsers (t, users) {
users.map(u => u.destroy()) users.map(u => u.destroy())
} }
function domFilter (nodeName, attrs) {
if (nodeName === 'HIDDEN') {
return null
}
attrs.delete('hidden')
return attrs
}
export async function initArrays (t, opts) { export async function initArrays (t, opts) {
var result = { var result = {
users: [] users: []
} }
var chance = opts.chance || new Chance(t.getSeed() * 1000000000) var chance = opts.chance || new Chance(t.getSeed() * 1000000000)
var conn = Object.assign({ room: 'debugging_' + t.name, generateUserId: false, testContext: t, chance }, connector) var conn = Object.assign({ room: 'debugging_' + t.name, testContext: t, chance }, connector)
for (let i = 0; i < opts.users; i++) { for (let i = 0; i < opts.users; i++) {
let connOpts let connOpts
if (i === 0) { if (i === 0) {
@ -146,23 +140,28 @@ export async function initArrays (t, opts) {
connOpts = Object.assign({ role: 'slave' }, conn) connOpts = Object.assign({ role: 'slave' }, conn)
} }
let y = new Y(connOpts.room, { let y = new Y(connOpts.room, {
_userID: i, // evil hackery, don't try this at home userID: i, // evil hackery, don't try this at home
connector: connOpts connector: connOpts
}) })
result.users.push(y) result.users.push(y)
result['array' + i] = y.define('array', Y.Array) result['array' + i] = y.define('array', Y.Array)
result['map' + i] = y.define('map', Y.Map) result['map' + i] = y.define('map', Y.Map)
result['xml' + i] = y.define('xml', Y.XmlElement) const yxml = y.define('xml', Y.XmlElement)
y.get('xml').setDomFilter(function (nodeName, attrs) { result['xml' + i] = yxml
if (nodeName === 'HIDDEN') { const dom = document.createElement('my-dom')
return null const domBinding = new DomBinding(yxml, dom, { domFilter })
} result['domBinding' + i] = domBinding
attrs.delete('hidden') result['dom' + i] = dom
return attrs const textType = y.define('text', Y.Text)
}) result['text' + i] = textType
const quill = new Quill(document.createElement('div'))
result['quillBinding' + i] = new Y.QuillBinding(textType, quill)
result['quill' + i] = quill
y.quill = quill // put quill on the y object (so we can use it later)
y.dom = dom
y.on('afterTransaction', function () { y.on('afterTransaction', function () {
for (let missing of y._missingStructs.values()) { for (let missing of y._missingStructs.values()) {
if (Array.from(missing.values()).length > 0) { if (missing.size > 0) {
console.error(new Error('Test check in "afterTransaction": missing should be empty!')) console.error(new Error('Test check in "afterTransaction": missing should be empty!'))
} }
} }

View File

@ -1,6 +1,6 @@
/* global Y */
import { wait } from './helper' import { wait } from './helper'
import { messageToString } from '../src/MessageHandler/messageToString' import { messageToString } from '../src/MessageHandler/messageToString'
import AbstractConnector from '../src/Connector.js'
var rooms = {} var rooms = {}
@ -64,8 +64,7 @@ function getTestRoom (roomname) {
return rooms[roomname] return rooms[roomname]
} }
export default function extendTestConnector (Y) { export default class TestConnector extends AbstractConnector {
class TestConnector extends Y.AbstractConnector {
constructor (y, options) { constructor (y, options) {
if (options === undefined) { if (options === undefined) {
throw new Error('Options must not be undefined!') throw new Error('Options must not be undefined!')
@ -161,10 +160,3 @@ export default function extendTestConnector (Y) {
return 'done' return 'done'
} }
} }
// TODO: this should be moved to a separate module (dont work on Y)
Y.test = TestConnector
}
if (typeof Y !== 'undefined') {
extendTestConnector(Y)
}