Compare commits

..

15 Commits

Author SHA1 Message Date
Kevin Jahns
eec63a008f 13.0.0-72 2018-11-20 03:53:55 +01:00
Kevin Jahns
52abcdd043 fix all tests 2018-11-16 12:33:41 +01:00
Kevin Jahns
f94653424a add prosemirror tests 2018-11-14 07:20:06 +01:00
Kevin Jahns
d67a794e2c 13.0.0-71 2018-11-09 01:49:59 +01:00
Kevin Jahns
60318083a6 make websocket-server a binary and add bindings and provider to npm package 2018-11-09 01:49:43 +01:00
Kevin Jahns
7607070452 13.0.0-70 2018-11-09 01:24:06 +01:00
Kevin Jahns
28fb7b6e9c remove logging in prosemirror binding 2018-11-09 01:23:16 +01:00
Kevin Jahns
aafe15757f implemented awareness protocol and added cursor support 2018-11-09 00:13:30 +01:00
Kevin Jahns
31d6ef6296 cleanup prosemirror example 2018-11-06 15:15:27 +01:00
Kevin Jahns
32b8fac37f added prosemirror binding 2018-11-06 13:44:35 +01:00
Kevin Jahns
e8060de914 13.0.0-69 2018-11-02 01:54:53 +01:00
Kevin Jahns
22b036527c further refine build process to also include lib 2018-11-02 01:54:40 +01:00
Kevin Jahns
feb1e030d7 13.0.0-68 2018-11-02 01:52:24 +01:00
Kevin Jahns
bd271e3952 update publish process 2018-11-02 01:52:20 +01:00
Kevin Jahns
df80938190 13.0.0-67 2018-11-02 00:47:09 +01:00
57 changed files with 1183 additions and 385 deletions

4
.gitignore vendored
View File

@@ -2,7 +2,7 @@ node_modules
bower_components
docs
/y.*
/examples/yjs-dist.js*
/examples/*/index.dist.*
.vscode
.yjsPersisted
build
build

67
bindings/BindMapping.js Normal file
View File

@@ -0,0 +1,67 @@
/**
* Type that maps from Yjs type to Target type.
* Used to implement double bindings.
*
* @template Y
* @template T
*/
export default class BindMapping {
/**
*/
constructor () {
/**
* @type Map<Y, T>
*/
this.yt = new Map()
/**
* @type Map<T, Y>
*/
this.ty = new Map()
}
/**
* Map y to t. Removes all existing bindings from y and t
* @param {Y} y
* @param {T} t
*/
bind (y, t) {
const existingT = this.yt.get(y)
if (existingT !== undefined) {
this.ty.delete(existingT)
}
const existingY = this.ty.get(t)
if (existingY !== undefined) {
this.yt.delete(existingY)
}
this.yt.set(y, t)
this.ty.set(t, y)
}
/**
* @param {Y} y
* @return {boolean}
*/
hasY (y) {
return this.yt.has(y)
}
/**
* @param {T} t
* @return {boolean}
*/
hasT (t) {
return this.ty.has(t)
}
/**
* @param {Y} y
* @return {T}
*/
getY (y) {
return this.yt.get(y)
}
/**
* @param {T} t
* @return {Y}
*/
getT (t) {
return this.ty.get(t)
}
}

View File

@@ -1,5 +1,5 @@
import { createMutex } from '../../lib/mutex.js'
import { createMutex } from '../lib/mutex.js'
/**
* Abstract class for bindings.

View File

@@ -1,6 +1,6 @@
/* global MutationObserver, getSelection */
import { fromRelativePosition } from '../../Util/relativePosition.js'
import { fromRelativePosition } from '../../src/Util/relativePosition.js'
import Binding from '../Binding.js'
import { createAssociation, removeAssociation } from './util.js'
import { beforeTransactionSelectionFixer, afterTransactionSelectionFixer, getCurrentRelativeSelection } from './selection.js'
@@ -121,8 +121,15 @@ export default class DomBinding extends Binding {
createAssociation(this, target, type)
}
flushDomChanges () {
this._domObserver(this._mutationObserver.takeRecords())
}
/**
* NOTE: currently does not apply filter to existing elements!
* NOTE:
* * does not apply filter to existing elements!
* * only guarantees that changes are filtered locally. Remote sites may see different content.
*
* @param {DomFilter} filter The filter function to use from now on.
*/
setFilter (filter) {

View File

@@ -1,11 +1,11 @@
import YXmlHook from '../../Types/YXml/YXmlHook.js'
import YXmlHook from '../../src/Types/YXml/YXmlHook.js'
import {
iterateUntilUndeleted,
removeAssociation,
insertNodeHelper } from './util.js'
import diff from '../../../lib/simpleDiff.js'
import YXmlFragment from '../../Types/YXml/YXmlFragment.js'
import diff from '../../lib/simpleDiff.js'
import YXmlFragment from '../../src/Types/YXml/YXmlFragment.js'
/**
* 1. Check if any of the nodes was deleted

View File

@@ -1,7 +1,7 @@
/* eslint-env browser */
import YXmlText from '../../Types/YXml/YXmlText.js'
import YXmlHook from '../../Types/YXml/YXmlHook.js'
import YXmlElement from '../../Types/YXml/YXmlElement.js'
import YXmlText from '../../src/Types/YXml/YXmlText.js'
import YXmlHook from '../../src/Types/YXml/YXmlHook.js'
import YXmlElement from '../../src/Types/YXml/YXmlElement.js'
import { createAssociation, domsToTypes } from './util.js'
import { filterDomAttributes, defaultFilter } from './filter.js'

View File

@@ -1,4 +1,4 @@
import isParentOf from '../../Util/isParentOf.js'
import isParentOf from '../../src/Util/isParentOf.js'
/**
* @callback DomFilter

View File

@@ -1,6 +1,6 @@
/* globals getSelection */
import { getRelativePosition } from '../../Util/relativePosition.js'
import { getRelativePosition } from '../../src/Util/relativePosition.js'
let relativeSelection = null

View File

@@ -1,8 +1,8 @@
/* eslint-env browser */
/* global getSelection */
import YXmlText from '../../Types/YXml/YXmlText.js'
import YXmlHook from '../../Types/YXml/YXmlHook.js'
import YXmlText from '../../src/Types/YXml/YXmlText.js'
import YXmlHook from '../../src/Types/YXml/YXmlHook.js'
import { removeDomChildrenUntilElementFound } from './util.js'
function findScrollReference (scrollingElement) {

View File

@@ -2,9 +2,9 @@
import domToType from './domToType.js'
/**
* @typedef {import('../../Types/YXml/YXmlText.js').default} YXmlText
* @typedef {import('../../Types/YXml/YXmlElement.js').default} YXmlElement
* @typedef {import('../../Types/YXml/YXmlHook.js').default} YXmlHook
* @typedef {import('../../src/Types/YXml/YXmlText.js').default} YXmlText
* @typedef {import('../../src/Types/YXml/YXmlElement.js').default} YXmlElement
* @typedef {import('../../src/Types/YXml/YXmlHook.js').default} YXmlHook
* @typedef {import('./DomBinding.js').default} DomBinding
*/

View File

@@ -0,0 +1,269 @@
import BindMapping from '../BindMapping.js'
import * as PModel from 'prosemirror-model'
import * as Y from '../../src/index.js'
import { createMutex } from '../../lib/mutex.js'
import { Plugin, PluginKey } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
/**
* @typedef {import('prosemirror-view').EditorView} EditorView
* @typedef {import('prosemirror-state').EditorState} EditorState
* @typedef {BindMapping<Y.Text | Y.XmlElement, PModel.Node>} ProsemirrorMapping
*/
export const prosemirrorPluginKey = new PluginKey('yjs')
/**
* This plugin listens to changes in prosemirror view and keeps yXmlState and view in sync.
*
* This plugin also keeps references to the type and the shared document so other plugins can access it.
* @param {Y.XmlFragment} yXmlFragment
*/
export const prosemirrorPlugin = yXmlFragment => {
const pluginState = {
type: yXmlFragment,
y: yXmlFragment._y,
binding: null
}
const plugin = new Plugin({
key: prosemirrorPluginKey,
state: {
init: (initargs, state) => {
return pluginState
},
apply: (tr, pluginState) => {
return pluginState
}
},
view: view => {
const binding = new ProsemirrorBinding(yXmlFragment, view)
pluginState.binding = binding
return {
update: () => {
binding._prosemirrorChanged()
},
destroy: () => {
binding.destroy()
}
}
}
})
return plugin
}
export const cursorPluginKey = new PluginKey('yjs-cursor')
export const cursorPlugin = new Plugin({
key: cursorPluginKey,
props: {
decorations: state => {
const y = prosemirrorPluginKey.getState(state).y
const awareness = y.getAwarenessInfo()
const decorations = []
awareness.forEach((state, userID) => {
if (state.cursor != null) {
const username = `User: ${userID}`
decorations.push(Decoration.widget(state.cursor.from, () => {
const cursor = document.createElement('span')
cursor.classList.add('ProseMirror-yjs-cursor')
const user = document.createElement('div')
user.insertBefore(document.createTextNode(username), null)
cursor.insertBefore(user, null)
return cursor
}, { key: username }))
decorations.push(Decoration.inline(state.cursor.from, state.cursor.to, { style: 'background-color: #ffa50070' }))
}
})
return DecorationSet.create(state.doc, decorations)
}
},
view: view => {
const y = prosemirrorPluginKey.getState(view.state).y
const awarenessListener = () => {
view.updateState(view.state)
}
y.on('awareness', awarenessListener)
return {
update: () => {
const y = prosemirrorPluginKey.getState(view.state).y
const from = view.state.selection.from
const to = view.state.selection.to
const current = y.getLocalAwarenessInfo()
if (current.cursor == null || current.cursor.to !== to || current.cursor.from !== from) {
y.setAwarenessField('cursor', {
from, to
})
}
},
destroy: () => {
const y = prosemirrorPluginKey.getState(view.state).y
y.setAwarenessField('cursor', null)
y.off('awareness', awarenessListener)
}
}
}
})
export default class ProsemirrorBinding {
/**
* @param {Y.XmlFragment} yXmlFragment The bind source
* @param {EditorView} prosemirrorView The target binding
*/
constructor (yXmlFragment, prosemirrorView) {
this.type = yXmlFragment
this.prosemirrorView = prosemirrorView
this.mux = createMutex()
/**
* @type {ProsemirrorMapping}
*/
this.mapping = new BindMapping()
this._observeFunction = this._typeChanged.bind(this)
yXmlFragment.observeDeep(this._observeFunction)
}
_typeChanged (events) {
if (events.length === 0) {
return
}
this.mux(() => {
events.forEach(event => {
// recompute node for each parent
// except main node, compute main node in the end
let target = event.target
if (target !== this.type) {
do {
if (target.constructor === Y.XmlElement) {
createNodeFromYElement(target, this.prosemirrorView.state.schema, this.mapping)
}
target = target._parent
} while (target._parent !== this.type)
}
})
const fragmentContent = this.type.toArray().map(t => createNodeIfNotExists(t, this.prosemirrorView.state.schema, this.mapping))
const tr = this.prosemirrorView.state.tr.replace(0, this.prosemirrorView.state.doc.content.size, new PModel.Slice(new PModel.Fragment(fragmentContent), 0, 0))
this.prosemirrorView.updateState(this.prosemirrorView.state.apply(tr))
})
}
_prosemirrorChanged () {
this.mux(() => {
updateYFragment(this.type, this.prosemirrorView.state, this.mapping)
})
}
destroy () {
this.type.unobserveDeep(this._observeFunction)
}
}
/**
* @param {Y.XmlElement} el
* @param {PModel.Schema} schema
* @param {ProsemirrorMapping} mapping
* @return {PModel.Node}
*/
export const createNodeIfNotExists = (el, schema, mapping) => {
const node = mapping.getY(el)
if (node === undefined) {
return createNodeFromYElement(el, schema, mapping)
}
return node
}
/**
* @param {Y.XmlElement} el
* @param {PModel.Schema} schema
* @param {ProsemirrorMapping} mapping
* @return {PModel.Node}
*/
export const createNodeFromYElement = (el, schema, mapping) => {
const children = []
el.toArray().forEach(type => {
if (type.constructor === Y.XmlElement) {
children.push(createNodeIfNotExists(type, schema, mapping))
} else {
children.concat(createTextNodesFromYText(type, schema, mapping)).forEach(textchild => children.push(textchild))
}
})
const node = schema.node(el.nodeName.toLowerCase(), el.getAttributes(), el.toArray().map(t => createNodeIfNotExists(t, schema, mapping)))
mapping.bind(el, node)
return node
}
/**
* @param {Y.Text} text
* @param {PModel.Schema} schema
* @param {ProsemirrorMapping} mapping
* @return {Array<PModel.Node>}
*/
export const createTextNodesFromYText = (text, schema, mapping) => {
const nodes = []
const deltas = text.toDelta()
for (let i = 0; i < deltas.length; i++) {
const delta = deltas[i]
const marks = []
for (let markName in delta.attributes) {
marks.push(schema.mark(markName, delta.attributes[markName]))
}
nodes.push(schema.text(delta.insert, marks))
}
if (nodes.length > 0) {
mapping.bind(text, nodes[0]) // only map to first child, all following children are also considered bound to this type
}
return nodes
}
/**
* @param {PModel.Node} node
* @param {ProsemirrorMapping} mapping
* @return {Y.XmlElement | Y.Text}
*/
export const createTypeFromNode = (node, mapping) => {
let type
if (node.isText) {
type = new Y.Text()
const attrs = {}
node.marks.forEach(mark => { attrs[mark.type.name] = mark.attrs })
type.insert(0, node.text, attrs)
} else {
type = new Y.XmlElement(node.type.name)
for (let key in node.attrs) {
type.setAttribute(key, node.attrs[key])
}
type.insert(0, node.content.content.map(node => createTypeFromNode(node, mapping)))
}
mapping.bind(type, node)
return type
}
/**
* @param {Y.XmlFragment} yDomFragment
* @param {EditorState} state
* @param {BindMapping} mapping
*/
const updateYFragment = (yDomFragment, state, mapping) => {
const pChildCnt = state.doc.content.childCount
const yChildren = yDomFragment.toArray()
const yChildCnt = yChildren.length
const minCnt = pChildCnt < yChildCnt ? pChildCnt : yChildCnt
let left = 0
let right = 0
// find number of matching elements from left
for (;left < minCnt; left++) {
if (state.doc.content.child(left) !== mapping.getY(yChildren[left])) {
break
}
}
// find number of matching elements from right
for (;right < minCnt; right++) {
if (state.doc.content.child(pChildCnt - right - 1) !== mapping.getY(yChildren[yChildCnt - right - 1])) {
break
}
}
if (left + right > pChildCnt) {
// nothing changed
return
}
yDomFragment._y.transact(() => {
// now update y to match editor state
yDomFragment.delete(left, yChildCnt - left - right)
yDomFragment.insert(left, state.doc.content.content.slice(left, pChildCnt - right).map(node => createTypeFromNode(node, mapping)))
})
}

View File

@@ -1,7 +1,7 @@
import Binding from '../Binding.js'
import simpleDiff from '../../../lib/simpleDiff.js'
import { getRelativePosition, fromRelativePosition } from '../../Util/relativePosition.js'
import simpleDiff from '../../lib/simpleDiff.js'
import { getRelativePosition, fromRelativePosition } from '../../src/Util/relativePosition.js'
function typeObserver () {
this._mutualExclude(() => {

View File

@@ -1,19 +0,0 @@
{
"name": "yjs-examples",
"version": "0.0.0",
"homepage": "y-js.org",
"authors": [
"Kevin Jahns <kevin.jahns@rwth-aachen.de>"
],
"description": "Examples for Yjs",
"license": "MIT",
"ignore": [],
"dependencies": {
"quill": "^1.0.0-rc.2",
"ace": "~1.2.3",
"ace-builds": "~1.2.3",
"jquery": "~2.2.2",
"d3": "^3.5.16",
"codemirror": "^5.25.0"
}
}

View File

@@ -1,7 +1,7 @@
import YWebsocketsConnector from '../../src/Connectors/WebsocketsConnector/WebsocketsConnector.js'
import Y from '../../src/Y.js'
import DomBinding from '../../src/Bindings/DomBinding/DomBinding.js'
import DomBinding from '../../bindings/DomBinding/DomBinding.js'
import UndoManager from '../../src/Util/UndoManager.js'
import YXmlFragment from '../../src/Types/YXml/YXmlFragment.js'
import YXmlText from '../../src/Types/YXml/YXmlText.js'

View File

@@ -18,7 +18,6 @@
</style>
<script src="../../y.js"></script>
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
<script src='../../../y-indexeddb/y-indexeddb.js'></script>
<script src="./index.js"></script>
</body>
</html>

View File

@@ -1,55 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<style>
.wrapper {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-gap: 7px;
}
.one {
grid-column: 1 ;
}
.two {
grid-column: 2;
}
.three {
grid-column: 3;
}
textarea {
width: calc(100% - 10px)
}
.editor-container {
background-color: #4caf50;
padding: 4px 5px 10px 5px;
border-radius: 11px;
}
.editor-container[disconnected] {
background-color: red;
}
.disconnected-info {
display: none;
}
.editor-container[disconnected] .disconnected-info {
display: inline;
}
</style>
<div class="wrapper">
<div id="container1" class="one editor-container">
<h1>Server 1 <span class="disconnected-info">(disconnected)</span></h1>
<textarea id="textarea1" rows=40 autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
</div>
<div id="container2" class="two editor-container">
<h1>Server 2 <span class="disconnected-info">(disconnected)</span></h1>
<textarea id="textarea2" rows=40 autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
</div>
<div id="container3" class="three editor-container">
<h1>Server 3 <span class="disconnected-info">(disconnected)</span></h1>
<textarea id="textarea3" rows=40 autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
</div>
</div>
<script src="../../y.js"></script>
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
<script src="./index.js"></script>
</body>
</html>

View File

@@ -1,38 +0,0 @@
/* global Y */
function bindYjsInstance (y, suffix) {
y.define('textarea', Y.Text).bind(document.getElementById('textarea' + suffix))
y.connector.socket.on('connection', function () {
document.getElementById('container' + suffix).removeAttribute('disconnected')
})
y.connector.socket.on('disconnect', function () {
document.getElementById('container' + suffix).setAttribute('disconnected', true)
})
}
let y1 = new Y('infinite-example', {
connector: {
name: 'websockets-client',
url: 'http://127.0.0.1:1234'
}
})
window.y1 = y1
bindYjsInstance(y1, '1')
let y2 = new Y('infinite-example', {
connector: {
name: 'websockets-client',
url: 'http://127.0.0.1:1234'
}
})
window.y2 = y2
bindYjsInstance(y2, '2')
let y3 = new Y('infinite-example', {
connector: {
name: 'websockets-client',
url: 'http://127.0.0.1:1234'
}
})
window.y3 = y3
bindYjsInstance(y1, '3')

View File

@@ -3,7 +3,7 @@
import { createYdbClient } from '../../YdbClient/index.js'
import Y from '../../src/Y.dist.js'
import * as ydb from '../../YdbClient/YdbClient.js'
import DomBinding from '../../src/Bindings/DomBinding/DomBinding.js'
import DomBinding from '../../bindings/DomBinding/DomBinding.js'
const uuidv4 = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0

View File

@@ -1,23 +0,0 @@
{
"name": "examples",
"version": "0.0.0",
"description": "",
"scripts": {
"dist": "rollup -c",
"watch": "rollup -cw"
},
"author": "Kevin Jahns",
"license": "MIT",
"dependencies": {
"monaco-editor": "^0.8.3",
"rollup": "^0.52.3"
},
"devDependencies": {
"standard": "^10.0.2"
},
"standard": {
"ignore": [
"bower_components"
]
}
}

View File

@@ -0,0 +1,73 @@
/* eslint-env browser */
import {Plugin} from 'prosemirror-state'
import {Decoration, DecorationSet} from 'prosemirror-view'
import {schema} from 'prosemirror-schema-basic'
const findPlaceholder = (state, id) => {
let decos = PlaceholderPlugin.getState(state)
let found = decos.find(null, null, spec => spec.id === id)
return found.length ? found[0].from : null
}
export const startImageUpload = (view, file) => {
// A fresh object to act as the ID for this upload
let id = {}
// Replace the selection with a placeholder
let tr = view.state.tr
if (!tr.selection.empty) tr.deleteSelection()
tr.setMeta(PlaceholderPlugin, {add: {id, pos: tr.selection.from}})
view.dispatch(tr)
uploadFile(file).then(url => {
let pos = findPlaceholder(view.state, id)
// If the content around the placeholder has been deleted, drop
// the image
if (pos == null) return
// Otherwise, insert it at the placeholder's position, and remove
// the placeholder
view.dispatch(view.state.tr
.replaceWith(pos, pos, schema.nodes.image.create({src: url}))
.setMeta(PlaceholderPlugin, {remove: {id}}))
}, () => {
// On failure, just clean up the placeholder
view.dispatch(tr.setMeta(PlaceholderPlugin, {remove: {id}}))
})
}
// This is just a dummy that loads the file and creates a data URL.
// You could swap it out with a function that does an actual upload
// and returns a regular URL for the uploaded file.
function uploadFile (file) {
let reader = new FileReader()
return new Promise((resolve, reject) => {
reader.onload = () => resolve(reader.result)
reader.onerror = () => reject(reader.error)
// Some extra delay to make the asynchronicity visible
setTimeout(() => reader.readAsDataURL(file), 1500)
})
}
export const PlaceholderPlugin = new Plugin({
state: {
init () { return DecorationSet.empty },
apply (tr, set) {
// Adjust decoration positions to changes made by the transaction
set = set.map(tr.mapping, tr.doc)
// See if the transaction adds or removes any placeholders
let action = tr.getMeta(this)
if (action && action.add) {
let widget = document.createElement('placeholder')
let deco = Decoration.widget(action.add.pos, widget, {id: action.add.id})
set = set.add(tr.doc, [deco])
} else if (action && action.remove) {
set = set.remove(set.find(null, null, spec => spec.id === action.remove.id))
}
return set
}
},
props: {
decorations (state) { return this.getState(state) }
}
})

View File

@@ -0,0 +1,21 @@
# Prosemirror Example
### Run basic websockets server
```sh
node /provider/websocket/server.js
```
### Bundle Prosemirror Example
This example requires external modules and needs to be bundled before shipping it to the browser.
```sh
cd /examples/prosemirror/
# bundle prosemirror example
npx rollup -wc
# serve example
npx serve .
```

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<script type="module" src="./index.dist.js"></script>
<link rel=stylesheet href="https://prosemirror.net/css/editor.css">
<style>
placeholder {
display: inline;
border: 1px solid #ccc;
color: #ccc;
}
placeholder:after {
content: "☁";
font-size: 200%;
line-height: 0.1;
font-weight: bold;
}
.ProseMirror img { max-width: 100px }
.ProseMirror-yjs-cursor {
position: absolute;
border-left: black;
border-left-style: solid;
border-left-width: 2px;
border-color: orange;
height: 1em;
}
.ProseMirror-yjs-cursor > div {
position: relative;
top: -1.05em;
font-size: 13px;
background-color: rgb(250, 129, 0);
font-family: serif;
font-style: normal;
font-weight: normal;
line-height: normal;
user-select: none;
color: white;
padding-left: 2px;
padding-right: 2px;
}
</style>
</head>
<body>
<div id="editor" style="margin-bottom: 23px"></div>
<div style="display: none" id="content">
<h3>Hello User</h3>
<p>type something ...</p>
</div>
<div>Insert image: <input type=file id=image-upload></div>
</body>
</html>

View File

@@ -0,0 +1,45 @@
/* eslint-env browser */
import * as Y from '../../src/index.js'
import { prosemirrorPlugin, cursorPlugin } from '../../bindings/ProsemirrorBinding/ProsemirrorBinding.js'
import WebsocketProvider from '../../provider/websocket/WebSocketProvider.js'
import {EditorState} from 'prosemirror-state'
import {EditorView} from 'prosemirror-view'
import {Schema, DOMParser, Mark, Fragment, Node, Slice} from 'prosemirror-model'
import {schema} from 'prosemirror-schema-basic'
import {exampleSetup} from 'prosemirror-example-setup'
import { PlaceholderPlugin, startImageUpload } from './PlaceholderPlugin.js'
const provider = new WebsocketProvider('ws://localhost:1234/')
const ydocument = provider.get('prosemirror')
/**
* @type {any}
*/
const type = ydocument.define('prosemirror', Y.XmlFragment)
const view = new EditorView(document.querySelector('#editor'), {
state: EditorState.create({
doc: DOMParser.fromSchema(schema).parse(document.querySelector('#content')),
plugins: exampleSetup({schema}).concat([PlaceholderPlugin, prosemirrorPlugin(type), cursorPlugin])
})
})
window.provider = provider
window.ydocument = ydocument
window.type = type
window.view = view
window.EditorState = EditorState
window.EditorView = EditorView
window.Mark = Mark
window.Fragment = Fragment
window.Node = Node
window.Schema = Schema
window.Slice = Slice
document.querySelector('#image-upload').addEventListener('change', e => {
if (view.state.selection.$from.parent.inlineContent && e.target.files.length) {
startImageUpload(view, e.target.files[0])
}
view.focus()
})

View File

@@ -0,0 +1,20 @@
import nodeResolve from 'rollup-plugin-node-resolve'
import commonjs from 'rollup-plugin-commonjs'
export default {
input: './index.js',
output: {
name: 'index',
file: 'index.dist.js',
format: 'umd',
sourcemap: true
},
plugins: [
nodeResolve({
main: true,
module: true,
browser: true
}),
commonjs()
]
}

View File

@@ -1,29 +0,0 @@
import nodeResolve from 'rollup-plugin-node-resolve'
import commonjs from 'rollup-plugin-commonjs'
var pkg = require('./package.json')
export default {
input: 'yjs-dist.js',
name: 'Y',
output: {
file: 'yjs-dist.js',
format: 'umd'
},
plugins: [
nodeResolve({
main: true,
module: true,
browser: true
}),
commonjs()
],
sourcemap: true,
banner: `
/**
* ${pkg.name} - ${pkg.description}
* @version v${pkg.version}
* @license ${pkg.license}
*/
`
}

190
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "yjs",
"version": "13.0.0-66",
"version": "13.0.0-72",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -2012,6 +2012,12 @@
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
"dev": true
},
"crel": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/crel/-/crel-3.1.0.tgz",
"integrity": "sha512-VIGY44ERxx8lXVkOEfcB0A49OkjxkQNK+j+fHvoLy7GsGX1KKgAaQ+p9N0YgvQXu+X+ryUWGDeLx/fSI+w7+eg==",
"dev": true
},
"cross-spawn": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
@@ -4082,7 +4088,7 @@
},
"http-errors": {
"version": "1.6.3",
"resolved": "http://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz",
"integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=",
"dev": true,
"requires": {
@@ -5040,7 +5046,7 @@
},
"magic-string": {
"version": "0.22.5",
"resolved": "http://registry.npmjs.org/magic-string/-/magic-string-0.22.5.tgz",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.22.5.tgz",
"integrity": "sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w==",
"dev": true,
"requires": {
@@ -5061,7 +5067,7 @@
},
"marked": {
"version": "0.3.19",
"resolved": "http://registry.npmjs.org/marked/-/marked-0.3.19.tgz",
"resolved": "https://registry.npmjs.org/marked/-/marked-0.3.19.tgz",
"integrity": "sha512-ea2eGWOqNxPcXv8dyERdSr/6FmzvWwzjMxpfGB/sbMccXoct+xY+YukPD+QTUZwyvK7BZwcr4m21WBOW41pAkg==",
"dev": true
},
@@ -5363,6 +5369,12 @@
}
}
},
"orderedmap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-1.0.0.tgz",
"integrity": "sha1-2Q/Cuh7QhRkJB9YB3sbmpT+NQbo=",
"dev": true
},
"os-homedir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
@@ -5412,7 +5424,7 @@
},
"parchment": {
"version": "1.1.4",
"resolved": "http://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
"dev": true
},
@@ -5656,6 +5668,158 @@
"object-assign": "^4.1.1"
}
},
"prosemirror-commands": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.0.7.tgz",
"integrity": "sha512-IR8yMSdw7XlKuF68tydAak1J9P/lLD5ohsrL7pzoLsJAJAQU7mVPDXtGbQrrm0mesddFjcc1zNo/cJQN3lRYnA==",
"dev": true,
"requires": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"prosemirror-dropcursor": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.1.1.tgz",
"integrity": "sha512-GeUyMO/tOEf8MXrP7Xb7UIMrfK86OGh0fnyBrHfhav4VjY9cw65mNoqHy87CklE5711AhCP5Qzfp8RL/hVKusg==",
"dev": true,
"requires": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"prosemirror-example-setup": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prosemirror-example-setup/-/prosemirror-example-setup-1.0.1.tgz",
"integrity": "sha512-4NKWpdmm75Zzgq/dIrypRnkBNPx+ONKyoGF42a9g3VIVv0TWglf1CBNxt5kzCgli9xdfut/xE5B42F9DR6BLHw==",
"dev": true,
"requires": {
"prosemirror-commands": "^1.0.0",
"prosemirror-dropcursor": "^1.0.0",
"prosemirror-gapcursor": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-inputrules": "^1.0.0",
"prosemirror-keymap": "^1.0.0",
"prosemirror-menu": "^1.0.0",
"prosemirror-schema-list": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"prosemirror-gapcursor": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.0.3.tgz",
"integrity": "sha512-X+hJhr42PcHWiSWL+lI5f/UeOhXCxlBFb8M6O8aG1hssmaRrW7sS2/Fjg5jFV+pTdS1REFkmm1occh01FMdDIQ==",
"dev": true,
"requires": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"prosemirror-history": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.0.3.tgz",
"integrity": "sha512-IfFGbhafSx+R3aq7nLJGkXeu2iaUiP8mkU3aRu2uQcIIjU8Fq7RJfuvhIOJ2RNUoSyqF/ANkdTjnZ74F5eHs1Q==",
"dev": true,
"requires": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"rope-sequence": "^1.2.0"
}
},
"prosemirror-inputrules": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.0.1.tgz",
"integrity": "sha512-UHy22NmwxS5WIMQYkzraDttQAF8mpP82FfbJsmKFfx6jwkR/SZa+ZhbkLY0zKQ5fBdJN7euj36JG/B5iAlrpxA==",
"dev": true,
"requires": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"prosemirror-keymap": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.0.1.tgz",
"integrity": "sha512-e79ApE7PXXZMFtPz7WbjycjAFd1NPjgY1MkecVz98tqwlBSggXWXYQnWFk6x7UkmnBYRHHbXHkR/RXmu2wyBJg==",
"dev": true,
"requires": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^1.1.8"
}
},
"prosemirror-menu": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.0.5.tgz",
"integrity": "sha512-9Vrn7CC191v7FA4QrAkL8W1SrR73V3CRIYCDuk94R8oFVk4VxSFdoKVLHuvGzxZ8b5LCu3DMJfh86YW9uL4RkQ==",
"dev": true,
"requires": {
"crel": "^3.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"prosemirror-model": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.6.3.tgz",
"integrity": "sha512-iqIml664X9MUVGLz2nzK4xfAofX8+o7gs2mi2/k+pVD0qZ7th1Jm5eG3AsqWoEUIZuWeaOWCKpBl/dPnhIIWew==",
"dev": true,
"requires": {
"orderedmap": "^1.0.0"
}
},
"prosemirror-schema-basic": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.0.0.tgz",
"integrity": "sha512-xTFjtuLZgcRS4MoDbUyI9NSk/k/ACLGKZQcDXH18ctM9BOmP4z5rGZcA014fCF2FnMFOU+lKwusL0JjVrEectQ==",
"dev": true,
"requires": {
"prosemirror-model": "^1.0.0"
}
},
"prosemirror-schema-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.0.1.tgz",
"integrity": "sha512-AiLIX6qm6PEeDtMCKZLcSLi55WXo1ls7DnRK+4hSkoi0IIzNdxGsRlecCd3MzEu//DVz3nAEh+zEmslyW+uk8g==",
"dev": true,
"requires": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"prosemirror-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.2.2.tgz",
"integrity": "sha512-j8aC/kf9BJSCQau485I/9pj39XQoce+TqH5xzekT7WWFARTsRYFLJtiXBcCKakv1VSeev+sC3bJP0pLfz7Ft8g==",
"dev": true,
"requires": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"prosemirror-transform": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.1.3.tgz",
"integrity": "sha512-1O6Di5lOL1mp4nuCnQNkHY7l2roIW5y8RH4ZG3hMYmkmDEWzTaFFnxxAAHsE5ipGLBSRcTlP7SsDhYBIdSuLpQ==",
"dev": true,
"requires": {
"prosemirror-model": "^1.0.0"
}
},
"prosemirror-view": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.6.5.tgz",
"integrity": "sha512-brg8fExNrmklbLs8VJ7uvmo/Lh93EHErH47alI55hkJ12EF73K+t2+IyrlkJF84tt5wFBJ20LeSxF8HlJHXiYg==",
"dev": true,
"requires": {
"prosemirror-model": "^1.1.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"pseudomap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
@@ -5684,7 +5848,7 @@
},
"quill": {
"version": "1.3.6",
"resolved": "http://registry.npmjs.org/quill/-/quill-1.3.6.tgz",
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.6.tgz",
"integrity": "sha512-K0mvhimWZN6s+9OQ249CH2IEPZ9JmkFuCQeHAOQax3EZ2nDJ3wfGh59mnlQaZV2i7u8eFarx6wAtvQKgShojug==",
"dev": true,
"requires": {
@@ -6111,7 +6275,7 @@
},
"rollup-plugin-commonjs": {
"version": "8.4.1",
"resolved": "http://registry.npmjs.org/rollup-plugin-commonjs/-/rollup-plugin-commonjs-8.4.1.tgz",
"resolved": "https://registry.npmjs.org/rollup-plugin-commonjs/-/rollup-plugin-commonjs-8.4.1.tgz",
"integrity": "sha512-mg+WuD+jlwoo8bJtW3Mvx7Tz6TsIdMsdhuvCnDMoyjh0oxsVgsjB/N0X984RJCWwc5IIiqNVJhXeeITcc73++A==",
"dev": true,
"requires": {
@@ -6252,6 +6416,12 @@
"require-relative": "0.8.7"
}
},
"rope-sequence": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.2.2.tgz",
"integrity": "sha1-ScTlwvVKSOmQsFCSZ3HihxvLMc4=",
"dev": true
},
"run-async": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz",
@@ -6950,6 +7120,12 @@
"integrity": "sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==",
"dev": true
},
"w3c-keyname": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-1.1.8.tgz",
"integrity": "sha512-2HAdug8GTiu3b4NYhssdtY8PXRue3ICnh1IlxvZYl+hiINRq0GfNWei3XOPDg8L0PsxbmYjWVLuLj6BMRR/9vA==",
"dev": true
},
"webidl-conversions": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-2.0.1.tgz",

View File

@@ -1,9 +1,8 @@
{
"name": "yjs",
"version": "13.0.0-66",
"version": "13.0.0-72",
"description": "A framework for real-time p2p shared editing on any data",
"main": "./y.node.js",
"browser": "./y.js",
"main": "./build/node/index.js",
"module": "./src/index.js",
"scripts": {
"test": "npm run lint",
@@ -16,11 +15,17 @@
"postversion": "npm run dist"
},
"files": [
"y.*",
"src/*",
".esdoc.json",
"docs/*"
"docs/*",
"build/*",
"lib/*",
"provider/*",
"bindings/*"
],
"bin": {
"y-websockets": "provider/websocket/server.js"
},
"standard": {
"ignore": [
"/y.js",
@@ -57,6 +62,10 @@
"cutest": "^0.1.9",
"esdoc": "^1.1.0",
"esdoc-standard-plugin": "^1.0.0",
"prosemirror-example-setup": "^1.0.1",
"prosemirror-schema-basic": "^1.0.0",
"prosemirror-state": "^1.2.2",
"prosemirror-view": "^1.6.5",
"quill": "^1.3.6",
"quill-cursors": "^1.0.3",
"rollup": "^0.58.2",

View File

@@ -3,6 +3,9 @@
import * as Y from '../../src/index.js'
export * from '../../src/index.js'
const messageSync = 0
const messageAwareness = 1
const reconnectTimeout = 100
const setupWS = (doc, url) => {
@@ -12,10 +15,19 @@ const setupWS = (doc, url) => {
websocket.onmessage = event => {
const decoder = Y.createDecoder(event.data)
const encoder = Y.createEncoder()
doc.mux(() =>
Y.readMessage(decoder, encoder, doc)
)
if (Y.length(encoder) > 0) {
const messageType = Y.readVarUint(decoder)
switch (messageType) {
case messageSync:
Y.writeVarUint(encoder, messageSync)
doc.mux(() =>
Y.readSyncMessage(decoder, encoder, doc)
)
break
case messageAwareness:
Y.readAwarenessMessage(decoder, doc)
break
}
if (Y.length(encoder) > 1) {
websocket.send(Y.toBuffer(encoder))
}
}
@@ -34,8 +46,11 @@ const setupWS = (doc, url) => {
})
// always send sync step 1 when connected
const encoder = Y.createEncoder()
Y.writeVarUint(encoder, messageSync)
Y.writeSyncStep1(encoder, doc)
websocket.send(Y.toBuffer(encoder))
// force send stored awareness info
doc.setAwarenessField(null, null)
}
}
@@ -43,6 +58,7 @@ const broadcastUpdate = (y, transaction) => {
if (y.wsconnected && transaction.encodedStructsLen > 0) {
y.mux(() => {
const encoder = Y.createEncoder()
Y.writeVarUint(encoder, messageSync)
Y.writeUpdate(encoder, transaction.encodedStructsLen, transaction.encodedStructs)
y.ws.send(Y.toBuffer(encoder))
})
@@ -54,9 +70,29 @@ class WebsocketsSharedDocument extends Y.Y {
super()
this.wsconnected = false
this.mux = Y.createMutex()
this.ws = null
this._localAwarenessState = {}
this.awareness = new Map()
setupWS(this, url)
this.on('afterTransaction', broadcastUpdate)
}
getLocalAwarenessInfo () {
return this._localAwarenessState
}
getAwarenessInfo () {
return this.awareness
}
setAwarenessField (field, value) {
if (field !== null) {
this._localAwarenessState[field] = value
}
if (this.wsconnected) {
const encoder = Y.createEncoder()
Y.writeVarUint(encoder, messageAwareness)
Y.writeUsersStateChange(encoder, [{ userID: this.userID, state: this._localAwarenessState }])
this.ws.send(Y.toBuffer(encoder))
}
}
}
export default class WebsocketProvider {

View File

@@ -3,12 +3,16 @@ const WebSocket = require('ws')
const wss = new WebSocket.Server({ port: 1234 })
const docs = new Map()
const messageSync = 0
const messageAwareness = 1
const afterTransaction = (doc, transaction) => {
if (transaction.encodedStructsLen > 0) {
const encoder = Y.createEncoder()
Y.writeVarUint(encoder, messageSync)
Y.writeUpdate(encoder, transaction.encodedStructsLen, transaction.encodedStructs)
const message = Y.toBuffer(encoder)
doc.conns.forEach(conn => conn.send(message))
doc.conns.forEach((_, conn) => conn.send(message))
}
}
@@ -16,7 +20,12 @@ class WSSharedDoc extends Y.Y {
constructor () {
super()
this.mux = Y.createMutex()
this.conns = new Set()
/**
* Maps from conn to set of controlled user ids. Delete all user ids from awareness when this conn is closed
* @type {Map<Object, Set<number>>}
*/
this.conns = new Map()
this.awareness = new Map()
this.on('afterTransaction', afterTransaction)
}
}
@@ -24,9 +33,28 @@ class WSSharedDoc extends Y.Y {
const messageListener = (conn, doc, message) => {
const encoder = Y.createEncoder()
const decoder = Y.createDecoder(message)
Y.readMessage(decoder, encoder, doc)
if (Y.length(encoder) > 0) {
conn.send(Y.toBuffer(encoder))
const messageType = Y.readVarUint(decoder)
switch (messageType) {
case messageSync:
Y.writeVarUint(encoder, messageSync)
Y.readSyncMessage(decoder, encoder, doc)
if (Y.length(encoder) > 1) {
conn.send(Y.toBuffer(encoder))
}
break
case messageAwareness: {
Y.writeVarUint(encoder, messageAwareness)
const updates = Y.forwardAwarenessMessage(decoder, encoder)
updates.forEach(update => {
doc.awareness.set(update.userID, update.state)
doc.conns.get(conn).add(update.userID)
})
const buff = Y.toBuffer(encoder)
doc.conns.forEach((_, c) => {
c.send(buff)
})
break
}
}
}
@@ -38,16 +66,36 @@ const setupConnection = (conn, req) => {
doc = new WSSharedDoc()
docs.set(req.url.slice(1), doc)
}
doc.conns.add(conn)
doc.conns.set(conn, new Set())
// listen and reply to events
conn.on('message', message => messageListener(conn, doc, message))
conn.on('close', () =>
conn.on('close', () => {
const controlledIds = doc.conns.get(conn)
doc.conns.delete(conn)
)
const encoder = Y.createEncoder()
Y.writeVarUint(encoder, messageAwareness)
Y.writeUsersStateChange(encoder, Array.from(controlledIds).map(userID => {
doc.awareness.delete(userID)
return { userID, state: null }
}))
const buf = Y.toBuffer(encoder)
doc.conns.forEach((_, conn) => conn.send(buf))
})
// send sync step 1
const encoder = Y.createEncoder()
Y.writeVarUint(encoder, messageSync)
Y.writeSyncStep1(encoder, doc)
conn.send(Y.toBuffer(encoder))
if (doc.awareness.size > 0) {
const encoder = Y.createEncoder()
const userStates = []
doc.awareness.forEach((state, userID) => {
userStates.push({ state, userID })
})
Y.writeVarUint(encoder, messageAwareness)
Y.writeUsersStateChange(encoder, userStates)
conn.send(Y.toBuffer(encoder))
}
}
wss.on('connection', setupConnection)

View File

@@ -5,11 +5,11 @@ import commonjs from 'rollup-plugin-commonjs'
var pkg = require('./package.json')
export default {
input: 'src/Y.dist.js',
input: 'src/index.js',
name: 'Y',
sourcemap: true,
output: {
file: 'y.js',
file: 'build/umd/index.js',
format: 'umd'
},
plugins: [
@@ -20,21 +20,6 @@ export default {
}),
commonjs(),
babel(),
uglify({
mangle: {
except: ['YMap', 'Y', 'YArray', 'YText', 'YXmlHook', 'YXmlFragment', 'YXmlElement', 'YXmlEvent', 'YXmlText', 'YEvent', 'YArrayEvent', 'YMapEvent', 'Type', 'Delete', 'ItemJSON', 'ItemString', 'Item']
},
output: {
comments: function (node, comment) {
var text = comment.value
var type = comment.type
if (type === 'comment2') {
// multiline comment
return /@license/i.test(text)
}
}
}
})
],
banner: `
/**

View File

@@ -1,56 +0,0 @@
import Binding from '../Binding.js'
import simpleDiff from '../../Util/simpleDiff.js'
import { getRelativePosition, fromRelativePosition } from '../../Util/relativePosition.js'
function typeObserver () {
this._mutualExclude(() => {
const textarea = this.target
const textType = this.type
const relativeStart = getRelativePosition(textType, textarea.selectionStart)
const relativeEnd = getRelativePosition(textType, textarea.selectionEnd)
textarea.value = textType.toString()
const start = fromRelativePosition(textType._y, relativeStart)
const end = fromRelativePosition(textType._y, relativeEnd)
textarea.setSelectionRange(start, end)
})
}
function domObserver () {
this._mutualExclude(() => {
let diff = simpleDiff(this.type.toString(), this.target.value)
this.type.delete(diff.pos, diff.remove)
this.type.insert(diff.pos, diff.insert)
})
}
/**
* 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 {
constructor (textType, domTextarea) {
// Binding handles textType as this.type and domTextarea as this.target
super(textType, domTextarea)
// set initial value
domTextarea.value = textType.toString()
// Observers are handled by this class
this._typeObserver = typeObserver.bind(this)
this._domObserver = domObserver.bind(this)
textType.observe(this._typeObserver)
domTextarea.addEventListener('input', this._domObserver)
}
destroy () {
// Remove everything that is handled by this class
this.type.unobserve(this._typeObserver)
this.target.unobserve(this._domObserver)
super.destroy()
}
}

View File

@@ -1,7 +1,7 @@
import Tree from '../../lib/Tree.js'
import * as ID from '../Util/ID.js'
import { getStruct } from '../Util/structReferences.js'
import { stringifyID, stringifyItemID } from '../message.js'
import { stringifyID, stringifyItemID } from '../protocols/syncProtocol.js'
import GC from '../Struct/GC.js'
export default class OperationStore extends Tree {

View File

@@ -1,6 +1,6 @@
import { getStructReference } from '../Util/structReferences.js'
import * as ID from '../Util/ID.js'
import { stringifyID } from '../message.js'
import { stringifyID } from '../protocols/syncProtocol.js'
import { writeStructToTransaction } from '../Util/Transaction.js'
import * as decoding from '../../lib/decoding.js'
import * as encoding from '../../lib/encoding.js'

View File

@@ -47,10 +47,12 @@ export function splitHelper (y, a, b, diff) {
o = o._right
}
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)
if (y._transaction !== null) {
if (y._transaction.newTypes.has(a)) {
y._transaction.newTypes.add(b)
} else if (y._transaction.deletedStructs.has(a)) {
y._transaction.deletedStructs.add(b)
}
}
}
@@ -117,7 +119,7 @@ export default class Item {
*/
_copy () {
const C = this.constructor
return C()
return new C()
}
/**

View File

@@ -1,5 +1,5 @@
import Item from './Item.js'
import { logItemHelper } from '../message.js'
import { logItemHelper } from '../protocols/syncProtocol.js'
import * as encoding from '../../lib/encoding.js'
import * as decoding from '../../lib/decoding.js'

View File

@@ -1,5 +1,5 @@
import Item from './Item.js'
import { logItemHelper } from '../message.js'
import { logItemHelper } from '../protocols/syncProtocol.js'
import * as encoding from '../../lib/encoding.js'
import * as decoding from '../../lib/decoding.js'

View File

@@ -1,5 +1,5 @@
import Item, { splitHelper } from './Item.js'
import { logItemHelper } from '../message.js'
import { logItemHelper } from '../protocols/syncProtocol.js'
import * as encoding from '../../lib/encoding.js'
import * as decoding from '../../lib/decoding.js'

View File

@@ -1,5 +1,5 @@
import Item, { splitHelper } from './Item.js'
import { logItemHelper } from '../message.js'
import { logItemHelper } from '../protocols/syncProtocol.js'
import * as encoding from '../../lib/encoding.js'
import * as decoding from '../../lib/decoding.js'

View File

@@ -1,7 +1,7 @@
import Type from '../../Struct/Type.js'
import ItemJSON from '../../Struct/ItemJSON.js'
import ItemString from '../../Struct/ItemString.js'
import { stringifyItemID, logItemHelper } from '../../message.js'
import { stringifyItemID, logItemHelper } from '../../protocols/syncProtocol.js'
import YEvent from '../../Util/YEvent.js'
/**

View File

@@ -1,7 +1,7 @@
import Item from '../../Struct/Item.js'
import Type from '../../Struct/Type.js'
import ItemJSON from '../../Struct/ItemJSON.js'
import { logItemHelper } from '../../message.js'
import { logItemHelper } from '../../protocols/syncProtocol.js'
import YEvent from '../../Util/YEvent.js'
/**

View File

@@ -1,7 +1,7 @@
import ItemEmbed from '../../Struct/ItemEmbed.js'
import ItemString from '../../Struct/ItemString.js'
import ItemFormat from '../../Struct/ItemFormat.js'
import { logItemHelper } from '../../message.js'
import { logItemHelper } from '../../protocols/syncProtocol.js'
import { YArrayEvent, default as YArray } from '../YArray/YArray.js'
/**
@@ -499,6 +499,39 @@ export default class YText extends YArray {
return str
}
toDomString () {
return this.toDelta().map(delta => {
const nestedNodes = []
for (let nodeName in delta.attributes) {
const attrs = []
for (let key in delta.attributes[nodeName]) {
attrs.push({key, value: delta.attributes[nodeName][key]})
}
// sort attributes to get a unique order
attrs.sort((a, b) => a.key < b.key ? -1 : 1)
nestedNodes.push({ nodeName, attrs })
}
// sort node order to get a unique order
nestedNodes.sort((a, b) => a.nodeName < b.nodeName ? -1 : 1)
// now convert to dom string
let str = ''
for (let i = 0; i < nestedNodes.length; i++) {
const node = nestedNodes[i]
str += `<${node.nodeName}`
for (let j = 0; j < node.attrs.length; j++) {
const attr = node.attrs[i]
str += ` ${attr.key}="${attr.value}"`
}
str += '>'
}
str += delta.insert
for (let i = nestedNodes.length - 1; i >= 0; i--) {
str += `</${nestedNodes[i].nodeName}>`
}
return str
})
}
/**
* Apply a {@link Delta} on this shared YText type.
*

View File

@@ -1,6 +1,6 @@
import YMap from '../YMap/YMap.js'
import YXmlFragment from './YXmlFragment.js'
import { createAssociation } from '../../Bindings/DomBinding/util.js'
import { createAssociation } from '../../../bindings/DomBinding/util.js'
import * as encoding from '../../../lib/encoding.js'
import * as decoding from '../../../lib/decoding.js'
@@ -82,6 +82,10 @@ export default class YXmlElement extends YXmlFragment {
super._integrate(y)
}
toString () {
return this.toDomString()
}
/**
* Returns the string representation of this YXmlElement.
* The attributes are ordered by attribute-name, so you can easily use this
@@ -91,7 +95,7 @@ export default class YXmlElement extends YXmlFragment {
*
* @public
*/
toString () {
toDomString () {
const attrs = this.getAttributes()
const stringBuilder = []
const keys = []
@@ -106,7 +110,7 @@ export default class YXmlElement extends YXmlFragment {
}
const nodeName = this.nodeName.toLocaleLowerCase()
const attrsString = stringBuilder.length > 0 ? ' ' + stringBuilder.join(' ') : ''
return `<${nodeName}${attrsString}>${super.toString()}</${nodeName}>`
return `<${nodeName}${attrsString}>${super.toDomString()}</${nodeName}>`
}
/**
@@ -170,7 +174,7 @@ export default class YXmlElement extends YXmlFragment {
* nodejs)
* @param {Object<string, any>} [hooks={}] Optional property to customize how hooks
* are presented in the DOM
* @param {import('../../Bindings/DomBinding/DomBinding.js').default} [binding] You should not set this property. This is
* @param {import('../../../bindings/DomBinding/DomBinding.js').default} [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}

View File

@@ -1,13 +1,13 @@
import { createAssociation } from '../../Bindings/DomBinding/util.js'
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 '../../message.js'
import { logItemHelper } from '../../protocols/syncProtocol.js'
/**
* @typedef {import('./YXmlElement.js').default} YXmlElement
* @typedef {import('../../Bindings/DomBinding/DomBinding.js').default} DomBinding
* @typedef {import('../../../bindings/DomBinding/DomBinding.js').default} DomBinding
* @typedef {import('../../Y.js').default} Y
*/
@@ -113,13 +113,17 @@ export default class YXmlFragment extends YArray {
this._callEventHandler(transaction, new YXmlEvent(this, parentSubs, remote, transaction))
}
toString () {
return this.toDomString()
}
/**
* Get the string representation of all the children of this YXmlFragment.
*
* @return {string} The string representation of all children.
*/
toString () {
return this.map(xml => xml.toString()).join('')
toDomString () {
return this.map(xml => xml.toDomString()).join('')
}
/**

View File

@@ -1,10 +1,10 @@
import YMap from '../YMap/YMap.js'
import { createAssociation } from '../../Bindings/DomBinding/util.js'
import { createAssociation } from '../../../bindings/DomBinding/util.js'
import * as encoding from '../../../lib/encoding.js'
import * as decoding from '../../../lib/decoding.js'
/**
* @typedef {import('../../Bindings/DomBinding/DomBinding.js').default} DomBinding
* @typedef {import('../../../bindings/DomBinding/DomBinding.js').default} DomBinding
* @typedef {import('../../Y.js').default} Y
*/

View File

@@ -1,8 +1,8 @@
import YText from '../YText/YText.js'
import { createAssociation } from '../../Bindings/DomBinding/util.js'
import { createAssociation } from '../../../bindings/DomBinding/util.js'
/**
* @typedef {import('../../Bindings/DomBinding/DomBinding.js').default} DomBinding
* @typedef {import('../../../bindings/DomBinding/DomBinding.js').default} DomBinding
* @typedef {import('../../index.js').Y} Y
*/

View File

@@ -6,7 +6,7 @@ import { createRootID } from './Util/ID.js'
import NamedEventHandler from '../lib/NamedEventHandler.js'
import Transaction from './Util/Transaction.js'
import * as encoding from '../lib/encoding.js'
import * as message from './message.js'
import * as message from './protocols/syncProtocol.js'
import { integrateRemoteStructs } from './Util/integrateRemoteStructs.js'
/**

View File

@@ -30,13 +30,8 @@ export { default as XmlElement } from './Types/YXml/YXmlElement.js'
export { getRelativePosition, fromRelativePosition } from './Util/relativePosition.js'
export { registerStruct as registerType } from './Util/structReferences.js'
export { default as TextareaBinding } from './Bindings/TextareaBinding/TextareaBinding.js'
export { default as QuillBinding } from './Bindings/QuillBinding/QuillBinding.js'
export { default as DomBinding } from './Bindings/DomBinding/DomBinding.js'
export { default as domToType } from './Bindings/DomBinding/domToType.js'
export { domsToTypes, switchAssociation } from './Bindings/DomBinding/util.js'
export * from './message.js'
export * from './protocols/syncProtocol.js'
export * from './protocols/awarenessProtocol.js'
export * from '../lib/encoding.js'
export * from '../lib/decoding.js'
export * from '../lib/mutex.js'

View File

@@ -0,0 +1,98 @@
import * as encoding from '../../lib/encoding.js'
import * as decoding from '../../lib/decoding.js'
const messageUsersStateChanged = 0
/**
* @typedef {Object} UserStateUpdate
* @property {number} UserStateUpdate.userID
* @property {Object} state
*/
/**
* @param {encoding.Encoder} encoder
* @param {Array<UserStateUpdate>} stateUpdates
*/
export const writeUsersStateChange = (encoder, stateUpdates) => {
const len = stateUpdates.length
encoding.writeVarUint(encoder, messageUsersStateChanged)
encoding.writeVarUint(encoder, len)
for (let i = 0; i < len; i++) {
const {userID, state} = stateUpdates[i]
encoding.writeVarUint(encoder, userID)
encoding.writeVarString(encoder, JSON.stringify(state))
}
}
export const readUsersStateChange = (decoder, y) => {
const added = []
const updated = []
const removed = []
const len = decoding.readVarUint(decoder)
for (let i = 0; i < len; i++) {
const userID = decoding.readVarUint(decoder)
const state = JSON.parse(decoding.readVarString(decoder))
if (userID !== y.userID) {
if (state === null) {
if (y.awareness.has(userID)) {
y.awareness.delete(userID)
removed.push(userID)
}
} else {
if (y.awareness.has(userID)) {
updated.push(userID)
} else {
added.push(userID)
}
y.awareness.set(userID, state)
}
}
}
if (added.length > 0 || updated.length > 0 || removed.length > 0) {
y.emit('awareness', {
added, updated, removed
})
}
}
/**
* @param {decoding.Decoder} decoder
* @param {encoding.Encoder} encoder
*/
export const forwardUsersStateChange = (decoder, encoder) => {
const len = decoding.readVarUint(decoder)
const updates = []
encoding.writeVarUint(encoder, messageUsersStateChanged)
encoding.writeVarUint(encoder, len)
for (let i = 0; i < len; i++) {
const userID = decoding.readVarUint(decoder)
const state = decoding.readVarString(decoder)
encoding.writeVarUint(encoder, userID)
encoding.writeVarString(encoder, state)
updates.push({userID, state: JSON.parse(state)})
}
return updates
}
/**
* @param {decoding.Decoder} decoder
*/
export const readAwarenessMessage = (decoder, y) => {
switch (decoding.readVarUint(decoder)) {
case messageUsersStateChanged:
readUsersStateChange(decoder, y)
break
}
}
/**
* @param {decoding.Decoder} decoder
* @param {encoding.Encoder} encoder
*/
export const forwardAwarenessMessage = (decoder, encoder) => {
switch (decoding.readVarUint(decoder)) {
case messageUsersStateChanged:
return forwardUsersStateChange(decoder, encoder)
}
}

View File

@@ -1,17 +1,17 @@
import * as encoding from '../lib/encoding.js'
import * as decoding from '../lib/decoding.js'
import * as ID from './Util/ID.js'
import { getStruct } from './Util/structReferences.js'
import { deleteItemRange } from './Struct/Delete.js'
import { integrateRemoteStruct } from './Util/integrateRemoteStructs.js'
import Item from './Struct/Item.js'
import * as encoding from '../../lib/encoding.js'
import * as decoding from '../../lib/decoding.js'
import * as ID from '../Util/ID.js'
import { getStruct } from '../Util/structReferences.js'
import { deleteItemRange } from '../Struct/Delete.js'
import { integrateRemoteStruct } from '../Util/integrateRemoteStructs.js'
import Item from '../Struct/Item.js'
/**
* @typedef {import('./Store/StateStore.js').default} StateStore
* @typedef {import('./Y.js').default} Y
* @typedef {import('./Struct/Item.js').default} Item
* @typedef {import('./Store/StateStore.js').StateSet} StateSet
* @typedef {import('../Store/StateStore.js').default} StateStore
* @typedef {import('../Y.js').default} Y
* @typedef {import('../Struct/Item.js').default} Item
* @typedef {import('../Store/StateStore.js').StateSet} StateSet
*/
/**
@@ -54,7 +54,7 @@ export const stringifyDeleteSet = (decoder) => {
const dsLength = decoding.readUint32(decoder)
for (let i = 0; i < dsLength; i++) {
str += ' -' + decoding.readVarUint(decoder) + ':\n' // decodes user
const dvLength = decoding.readVarUint(decoder)
const dvLength = decoding.readUint32(decoder)
for (let j = 0; j < dvLength; j++) {
str += `clock: ${decoding.readVarUint(decoder)}, length: ${decoding.readVarUint(decoder)}, gc: ${decoding.readUint8(decoder) === 1}\n`
}
@@ -192,8 +192,8 @@ export const readDeleteSet = (decoder, y) => {
*/
export const stringifyStateSet = decoder => {
let s = 'State Set: '
readStateSet(decoder).forEach((user, userState) => {
s += `(${user}: ${userState}), `
readStateSet(decoder).forEach((clock, user) => {
s += `(${user}: ${clock}), `
})
return s
}
@@ -209,7 +209,7 @@ export const writeStateSet = (encoder, y) => {
// write as fixed-size number to stay consistent with the other encode functions.
// => anytime we write the number of objects that follow, encode as fixed-size number.
encoding.writeUint32(encoder, state.size)
state.forEach((user, clock) => {
state.forEach((clock, user) => {
encoding.writeVarUint(encoder, user)
encoding.writeVarUint(encoder, clock)
})
@@ -317,7 +317,9 @@ export const writeStructs = (encoder, y, ss) => {
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)
// TODO: only write partial content (only missing content)
// const struct = overlappingLeft._clonePartial(clock - rightID.clock)
const struct = overlappingLeft
struct._toBinary(encoder)
len++
}
@@ -439,7 +441,7 @@ export const readUpdate = readStructs
* @param {Y} y
* @return {string} The message converted to string
*/
export const stringifyMessage = (decoder, y) => {
export const stringifySyncMessage = (decoder, y) => {
const messageType = decoding.readVarUint(decoder)
let stringifiedMessage
let stringifiedMessageType
@@ -468,7 +470,7 @@ export const stringifyMessage = (decoder, y) => {
* @param {encoding.Encoder} encoder The reply message. Will not be sent if empty.
* @param {Y} y
*/
export const readMessage = (decoder, encoder, y) => {
export const readSyncMessage = (decoder, encoder, y) => {
const messageType = decoding.readVarUint(decoder)
switch (messageType) {
case messageYjsSyncStep1:

View File

@@ -13,8 +13,8 @@ function testEncoding (t, write, read, val) {
t.compare(val, result, 'Compare results')
}
const writeVarUint = (encoder, val) => encoder.writeVarUint(val)
const readVarUint = decoder => decoder.readVarUint()
const writeVarUint = (encoder, val) => encoding.writeVarUint(encoder, val)
const readVarUint = decoder => decoding.readVarUint(decoder)
test('varUint 1 byte', async function varUint1 (t) {
testEncoding(t, writeVarUint, readVarUint, 42)
@@ -46,8 +46,8 @@ test('varUint random user id', async function varUintRandomUserId (t) {
testEncoding(t, writeVarUint, readVarUint, generateRandomUint32())
})
const writeVarString = (encoder, val) => encoder.writeVarString(val)
const readVarString = decoder => decoder.readVarString()
const writeVarString = (encoder, val) => encoding.writeVarString(encoder, val)
const readVarString = decoder => decoding.readVarString(decoder)
test('varString', async function varString (t) {
testEncoding(t, writeVarString, readVarString, 'hello')

View File

@@ -1,5 +1,4 @@
// TODO: include all tests
import './red-black-tree.js'
import './y-array.tests.js'
import './y-text.tests.js'
@@ -7,3 +6,4 @@ import './y-map.tests.js'
import './y-xml.tests.js'
import './encode-decode.tests.js'
import './diff.tests.js'
import './prosemirror.test.js'

38
test/prosemirror.test.js Normal file
View File

@@ -0,0 +1,38 @@
import { test } from '../node_modules/cutest/cutest.mjs'
import * as random from '../lib/random/random.js'
import * as Y from '../src/index.js'
import { prosemirrorPlugin, cursorPlugin } from '../bindings/ProsemirrorBinding/ProsemirrorBinding.js'
import {EditorState} from 'prosemirror-state'
import {EditorView} from 'prosemirror-view'
import {Schema, DOMParser, Mark, Fragment, Node, Slice} from 'prosemirror-model'
import {schema} from 'prosemirror-schema-basic'
import {exampleSetup} from 'prosemirror-example-setup'
const createNewProsemirrorView = y => {
const view = new EditorView(document.createElement('div'), {
state: EditorState.create({
schema,
plugins: exampleSetup({schema}).concat([prosemirrorPlugin(y.define('prosemirror', Y.XmlFragment))])
})
})
return view
}
test('random prosemirror insertions', async t => {
const gen = random.createPRNG(t.getSeed())
const y = new Y.Y()
const p1 = createNewProsemirrorView(y)
const p2 = createNewProsemirrorView(y)
for (let i = 0; i < 10; i++) {
const p = random.oneOf(gen, [p1, p2])
const insertPos = random.int32(gen, 0, p.state.doc.content.size)
const overwrite = random.int32(gen, 0, p.state.doc.content.size - insertPos)
p.dispatch(p.state.tr.insertText('' + i, insertPos, insertPos + overwrite))
}
t.compare(
p1.state.doc.toJSON(),
p2.state.doc.toJSON(),
'compare prosemirror models'
)
})

View File

@@ -65,6 +65,7 @@ test('insertions work in late sync', async function array4 (t) {
array2.insert(1, ['user2'])
await users[1].connect()
await users[2].connect()
testConnector.flushAllMessages()
await compareUsers(t, users)
})
@@ -215,7 +216,7 @@ function getUniqueNumber () {
var arrayTransactions = [
function insert (t, user, prng) {
const yarray = user.get('array', Y.Array)
const yarray = user.define('array', Y.Array)
var uniqueNumber = getUniqueNumber()
var content = []
var len = random.int32(prng, 1, 4)
@@ -226,14 +227,14 @@ var arrayTransactions = [
yarray.insert(pos, content)
},
function insertTypeArray (t, user, prng) {
const yarray = user.get('array', Y.Array)
const yarray = user.define('array', Y.Array)
var pos = random.int32(prng, 0, yarray.length)
yarray.insert(pos, [Y.Array])
var array2 = yarray.get(pos)
array2.insert(0, [1, 2, 3, 4])
},
function insertTypeMap (t, user, prng) {
const yarray = user.get('array', Y.Array)
const yarray = user.define('array', Y.Array)
var pos = random.int32(prng, 0, yarray.length)
yarray.insert(pos, [Y.Map])
var map = yarray.get(pos)
@@ -242,7 +243,7 @@ var arrayTransactions = [
map.set('someprop', 44)
},
function _delete (t, user, prng) {
const yarray = user.get('array', Y.Array)
const yarray = user.define('array', Y.Array)
var length = yarray.length
if (length > 0) {
var somePos = random.int32(prng, 0, length - 1)

View File

@@ -50,7 +50,7 @@ test('Basic get&set of Map property (converge via sync)', async function map1 (t
testConnector.flushAllMessages()
for (let user of users) {
var u = user.get('map', Y.Map)
var u = user.define('map', Y.Map)
t.compare(u.get('stuff'), 'stuffy')
t.assert(u.get('undefined') === undefined, 'undefined')
t.compare(u.get('null'), null, 'null')
@@ -94,7 +94,7 @@ test('Basic get&set of Map property (converge via update)', async function map5
testConnector.flushAllMessages()
for (let user of users) {
var u = user.get('map', Y.Map)
var u = user.define('map', Y.Map)
t.compare(u.get('stuff'), 'stuffy')
}
await compareUsers(t, users)
@@ -108,7 +108,7 @@ test('Basic get&set of Map property (handle conflict)', async function map6 (t)
testConnector.flushAllMessages()
for (let user of users) {
var u = user.get('map', Y.Map)
var u = user.define('map', Y.Map)
t.compare(u.get('stuff'), 'c0')
}
await compareUsers(t, users)
@@ -121,7 +121,7 @@ test('Basic get&set&delete of Map property (handle conflict)', async function ma
map1.set('stuff', 'c1')
testConnector.flushAllMessages()
for (let user of users) {
var u = user.get('map', Y.Map)
var u = user.define('map', Y.Map)
t.assert(u.get('stuff') === undefined)
}
await compareUsers(t, users)
@@ -135,7 +135,7 @@ test('Basic get&set of Map property (handle three conflicts)', async function ma
map2.set('stuff', 'c3')
testConnector.flushAllMessages()
for (let user of users) {
var u = user.get('map', Y.Map)
var u = user.define('map', Y.Map)
t.compare(u.get('stuff'), 'c0')
}
await compareUsers(t, users)
@@ -155,7 +155,7 @@ test('Basic get&set&delete of Map property (handle three conflicts)', async func
map3.set('stuff', 'c3')
testConnector.flushAllMessages()
for (let user of users) {
var u = user.get('map', Y.Map)
var u = user.define('map', Y.Map)
t.assert(u.get('stuff') === undefined)
}
await compareUsers(t, users)
@@ -303,12 +303,12 @@ var mapTransactions = [
function set (t, user, prng) {
let key = random.oneOf(prng, ['one', 'two'])
var value = random.utf16String(prng)
user.get('map', Y.Map).set(key, value)
user.define('map', Y.Map).set(key, value)
},
function setType (t, user, prng) {
let key = random.oneOf(prng, ['one', 'two'])
var type = random.oneOf(prng, [new Y.Array(), new Y.Map()])
user.get('map', Y.Map).set(key, type)
user.define('map', Y.Map).set(key, type)
if (type instanceof Y.Array) {
type.insert(0, [1, 2, 3, 4])
} else {
@@ -317,7 +317,7 @@ var mapTransactions = [
},
function _delete (t, user, prng) {
let key = random.oneOf(prng, ['one', 'two'])
user.get('map', Y.Map).delete(key)
user.define('map', Y.Map).delete(key)
}
]

View File

@@ -61,20 +61,24 @@ test('attribute modifications (y -> dom)', async function xml2 (t) {
})
test('attribute modifications (dom -> y)', async function xml3 (t) {
var { users, xml0, dom0 } = await initArrays(t, { users: 3 })
var { users, xml0, dom0, domBinding0 } = await initArrays(t, { users: 3 })
dom0.setAttribute('height', '100px')
domBinding0.flushDomChanges()
t.assert(xml0.getAttribute('height') === '100px', 'setAttribute')
dom0.removeAttribute('height')
domBinding0.flushDomChanges()
t.assert(xml0.getAttribute('height') == null, 'removeAttribute')
dom0.setAttribute('class', 'stuffy stuff')
domBinding0.flushDomChanges()
t.assert(xml0.getAttribute('class') === 'stuffy stuff', 'set class attribute')
await compareUsers(t, users)
})
test('element insert (dom -> y)', async function xml4 (t) {
var { users, xml0, dom0 } = await initArrays(t, { users: 3 })
var { users, xml0, dom0, domBinding0 } = await initArrays(t, { users: 3 })
dom0.insertBefore(document.createTextNode('some text'), null)
dom0.insertBefore(document.createElement('p'), null)
domBinding0.flushDomChanges()
t.assert(xml0.get(0).toString() === 'some text', 'Retrieve Text Node')
t.assert(xml0.get(1).nodeName === 'P', 'Retrieve Element node')
await compareUsers(t, users)
@@ -90,10 +94,12 @@ test('element insert (y -> dom)', async function xml5 (t) {
})
test('y on insert, then delete (dom -> y)', async function xml6 (t) {
var { users, xml0, dom0 } = await initArrays(t, { users: 3 })
var { users, xml0, dom0, domBinding0 } = await initArrays(t, { users: 3 })
dom0.insertBefore(document.createElement('p'), null)
domBinding0.flushDomChanges()
t.assert(xml0.length === 1, 'one node present')
dom0.childNodes[0].remove()
domBinding0.flushDomChanges()
t.assert(xml0.length === 0, 'no node present after delete')
await compareUsers(t, users)
})
@@ -164,11 +170,13 @@ test('Receive a bunch of elements (with disconnect)', async function xml12 (t) {
})
test('move element to a different position', async function xml13 (t) {
var { testConnector, users, dom0, dom1 } = await initArrays(t, { users: 3 })
var { testConnector, users, dom0, dom1, domBinding0, domBinding1 } = await initArrays(t, { users: 3 })
dom0.append(document.createElement('div'))
dom0.append(document.createElement('h1'))
domBinding0.flushDomChanges()
testConnector.flushAllMessages()
dom1.insertBefore(dom1.childNodes[0], null)
domBinding1.flushDomChanges()
t.assert(dom1.childNodes[0].nodeName === 'H1', 'div was deleted (user 0)')
t.assert(dom1.childNodes[1].nodeName === 'DIV', 'div was moved to the correct position (user 0)')
t.assert(dom1.childNodes[0].nodeName === 'H1', 'div was deleted (user 1)')
@@ -189,6 +197,7 @@ test('filter node', async function xml14 (t) {
domBinding1.setFilter(domFilter)
dom0.append(document.createElement('div'))
dom0.append(document.createElement('h1'))
domBinding0.flushDomChanges()
testConnector.flushAllMessages()
t.assert(dom1.childNodes.length === 1, 'Only one node was not transmitted')
t.assert(dom1.childNodes[0].nodeName === 'DIV', 'div node was transmitted')
@@ -206,6 +215,7 @@ test('filter attribute', async function xml15 (t) {
dom0.setAttribute('hidden', 'true')
dom0.setAttribute('style', 'height: 30px')
dom0.setAttribute('data-me', '77')
domBinding0.flushDomChanges()
testConnector.flushAllMessages()
t.assert(dom0.getAttribute('hidden') === 'true', 'User 0 still has the attribute')
t.assert(dom1.getAttribute('hidden') == null, 'User 1 did not receive update')
@@ -215,7 +225,7 @@ test('filter attribute', async function xml15 (t) {
})
test('deep element insert', async function xml16 (t) {
var { testConnector, users, dom0, dom1 } = await initArrays(t, { users: 3 })
var { testConnector, users, dom0, dom1, domBinding0 } = await initArrays(t, { users: 3 })
let deepElement = document.createElement('p')
let boldElement = document.createElement('b')
let attrElement = document.createElement('img')
@@ -225,6 +235,7 @@ test('deep element insert', async function xml16 (t) {
deepElement.append(attrElement)
dom0.append(deepElement)
let str0 = dom0.outerHTML
domBinding0.flushDomChanges()
testConnector.flushAllMessages()
let str1 = dom1.outerHTML
t.compare(str0, str1, 'Dom string representation matches')
@@ -253,8 +264,8 @@ test('treeWalker', async function xml17 (t) {
* Incoming changes that contain malicious attributes should be deleted.
*/
test('Filtering remote changes', async function xmlFilteringRemote (t) {
var { testConnector, users, xml0, xml1, domBinding0 } = await initArrays(t, { users: 3 })
domBinding0.setFilter(function (nodeName, attributes) {
var { testConnector, users, xml0, xml1, domBinding0, domBinding1 } = await initArrays(t, { users: 3 })
const filter = (nodeName, attributes) => {
attributes.delete('malicious')
if (nodeName === 'HIDEME') {
return null
@@ -263,7 +274,9 @@ test('Filtering remote changes', async function xmlFilteringRemote (t) {
} else {
return attributes
}
})
}
domBinding0.setFilter(filter)
domBinding1.setFilter(filter)
let paragraph = new Y.XmlElement('p')
let hideMe = new Y.XmlElement('hideMe')
let span = new Y.XmlElement('span')
@@ -275,13 +288,16 @@ test('Filtering remote changes', async function xmlFilteringRemote (t) {
let tag2 = new Y.XmlElement('tag')
tag2.setAttribute('isHidden', 'true')
paragraph.insert(0, [tag2])
domBinding0.flushDomChanges()
testConnector.flushAllMessages()
// check dom
domBinding0.typeToDom.get(paragraph).setAttribute('malicious', 'true')
domBinding0.typeToDom.get(span).setAttribute('malicious', 'true')
domBinding0.flushDomChanges()
// check incoming attributes
xml1.get(0).get(0).setAttribute('malicious', 'true')
xml1.insert(0, [new Y.XmlElement('hideMe')])
domBinding0.flushDomChanges()
testConnector.flushAllMessages()
await compareUsers(t, users)
@@ -292,30 +308,36 @@ var xmlTransactions = [
function attributeChange (t, user, prng) {
// random.word generates non-empty words. prepend something
user.dom.setAttribute('_' + random.word(prng), random.word(prng))
user.domBinding.flushDomChanges()
},
function attributeChangeHidden (t, user, prng) {
user.dom.setAttribute('hidden', random.word(prng))
user.domBinding.flushDomChanges()
},
function insertText (t, user, prng) {
let dom = user.dom
var succ = dom.children.length > 0 ? random.oneOf(prng, dom.children) : null
dom.insertBefore(document.createTextNode(random.word(prng)), succ)
user.domBinding.flushDomChanges()
},
function insertHiddenDom (t, user, prng) {
let dom = user.dom
var succ = dom.children.length > 0 ? random.oneOf(prng, dom.children) : null
dom.insertBefore(document.createElement('hidden'), succ)
user.domBinding.flushDomChanges()
},
function insertDom (t, user, prng) {
let dom = user.dom
var succ = dom.children.length > 0 ? random.oneOf(prng, dom.children) : null
dom.insertBefore(document.createElement('my-' + random.word(prng)), succ)
user.domBinding.flushDomChanges()
},
function deleteChild (t, user, prng) {
let dom = user.dom
if (dom.childNodes.length > 0) {
var d = random.oneOf(prng, dom.childNodes)
d.remove()
user.domBinding.flushDomChanges()
}
},
function insertTextSecondLayer (t, user, prng) {
@@ -324,6 +346,7 @@ var xmlTransactions = [
let dom2 = random.oneOf(prng, dom.children)
let succ = dom2.childNodes.length > 0 ? random.oneOf(prng, dom2.childNodes) : null
dom2.insertBefore(document.createTextNode(random.word(prng)), succ)
user.domBinding.flushDomChanges()
}
},
function insertDomSecondLayer (t, user, prng) {
@@ -332,6 +355,7 @@ var xmlTransactions = [
let dom2 = random.oneOf(prng, dom.children)
let succ = dom2.childNodes.length > 0 ? random.oneOf(prng, dom2.childNodes) : null
dom2.insertBefore(document.createElement('my-' + random.word(prng)), succ)
user.domBinding.flushDomChanges()
}
},
function deleteChildSecondLayer (t, user, prng) {
@@ -342,6 +366,7 @@ var xmlTransactions = [
let d = random.oneOf(prng, dom2.childNodes)
d.remove()
}
user.domBinding.flushDomChanges()
}
}
]

View File

@@ -6,10 +6,12 @@ import { defragmentItemContent } from '../src/Util/defragmentItemContent.js'
import Quill from 'quill'
import GC from '../src/Struct/GC.js'
import * as random from '../lib/random/random.js'
import * as message from '../src/message.js'
import * as syncProtocol from '../src/protocols/syncProtocol.js'
import * as encoding from '../lib/encoding.js'
import * as decoding from '../lib/decoding.js'
import { createMutex } from '../lib/mutex.js'
import QuillBinding from '../bindings/QuillBinding/QuillBinding.js'
import DomBinding from '../bindings/DomBinding/DomBinding.js'
export * from '../src/index.js'
@@ -21,7 +23,7 @@ const afterTransaction = (y, transaction) => {
y.mMux(() => {
if (transaction.encodedStructsLen > 0) {
const encoder = encoding.createEncoder()
message.writeUpdate(encoder, transaction.encodedStructsLen, transaction.encodedStructs)
syncProtocol.writeUpdate(encoder, transaction.encodedStructsLen, transaction.encodedStructs)
broadcastMessage(y, encoding.toBuffer(encoder))
}
})
@@ -31,8 +33,9 @@ export class TestYInstance extends Y.Y {
/**
* @param {TestConnector} testConnector
*/
constructor (testConnector) {
constructor (testConnector, clientID) {
super()
this.userID = clientID // overwriting clientID
/**
* @type {TestConnector}
*/
@@ -64,17 +67,19 @@ export class TestYInstance extends Y.Y {
*/
connect () {
if (!this.tc.onlineConns.has(this)) {
this.tc.onlineConns.add(this)
const encoder = encoding.createEncoder()
message.writeSyncStep1(encoder, this)
syncProtocol.writeSyncStep1(encoder, this)
// publish SyncStep1
broadcastMessage(this, encoding.toBuffer(encoder))
this.tc.onlineConns.forEach(remoteYInstance => {
// remote instance sends instance to this instance
const encoder = encoding.createEncoder()
message.writeSyncStep1(encoder, remoteYInstance)
this._receive(encoding.toBuffer(encoder), remoteYInstance)
if (remoteYInstance !== this) {
// remote instance sends instance to this instance
const encoder = encoding.createEncoder()
syncProtocol.writeSyncStep1(encoder, remoteYInstance)
this._receive(encoding.toBuffer(encoder), remoteYInstance)
}
})
this.tc.onlineConns.add(this)
}
}
/**
@@ -117,9 +122,10 @@ export class TestConnector {
}
/**
* Create a new Y instance and add it to the list of connections
* @param {number} clientID
*/
createY () {
return new TestYInstance(this)
createY (clientID) {
return new TestYInstance(this, clientID)
}
/**
* Choose random connection and flush a random message from a random sender.
@@ -139,8 +145,9 @@ export class TestConnector {
}
const encoder = encoding.createEncoder()
receiver.mMux(() => {
console.log('receive (' + sender.userID + '->' + receiver.userID + '):\n', syncProtocol.stringifySyncMessage(decoding.createDecoder(m), receiver))
// do not publish data created when this function is executed (could be ss2 or update message)
message.readMessage(decoding.createDecoder(m), encoder, receiver)
syncProtocol.readSyncMessage(decoding.createDecoder(m), encoder, receiver)
})
if (encoding.length(encoder) > 0) {
// send reply message
@@ -202,12 +209,15 @@ export class TestConnector {
* @param {TestYInstance} y // publish message created by `y` to all other online clients
* @param {ArrayBuffer} m
*/
const broadcastMessage = (y, m) =>
y.tc.onlineConns.forEach(remoteYInstance => {
if (remoteYInstance !== y) {
remoteYInstance._receive(m, y)
}
})
const broadcastMessage = (y, m) => {
if (y.tc.onlineConns.has(y)) {
y.tc.onlineConns.forEach(remoteYInstance => {
if (remoteYInstance !== y) {
remoteYInstance._receive(m, y)
}
})
}
}
/**
* Convert DS to a proper DeleteSet of Map.
@@ -295,7 +305,7 @@ export function compareUsers (t, users) {
data.os = ops
data.ds = getDeleteSet(u)
const ss = {}
u.ss.state.forEach((user, clock) => {
u.ss.state.forEach((clock, user) => {
ss[user] = clock
})
data.ss = ss
@@ -347,20 +357,20 @@ export function initArrays (t, opts) {
const testConnector = new TestConnector(prng)
result.testConnector = testConnector
for (let i = 0; i < opts.users; i++) {
let y = testConnector.createY()
let y = testConnector.createY(i)
result.users.push(y)
result['array' + i] = y.define('array', Y.Array)
result['map' + i] = y.define('map', Y.Map)
const yxml = y.define('xml', Y.XmlElement)
result['xml' + i] = yxml
const dom = document.createElement('my-dom')
const domBinding = new Y.DomBinding(yxml, dom, { filter })
const domBinding = new DomBinding(yxml, dom, { filter })
result['domBinding' + i] = domBinding
result['dom' + i] = dom
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['quillBinding' + i] = new 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