implemented awareness protocol and added cursor support

This commit is contained in:
Kevin Jahns 2018-11-09 00:13:30 +01:00
parent 31d6ef6296
commit aafe15757f
19 changed files with 391 additions and 100 deletions

View File

@ -2,6 +2,8 @@ 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
@ -9,65 +11,147 @@ import { createMutex } from '../../lib/mutex.js'
* @typedef {BindMapping<Y.Text | Y.XmlElement, PModel.Node>} ProsemirrorMapping
*/
export default class ProsemirrorBinding {
/**
* @param {Y.XmlFragment} yDomFragment The bind source
* @param {EditorView} prosemirror The target binding
*/
constructor (yDomFragment, prosemirror) {
this.type = yDomFragment
this.prosemirror = prosemirror
const mux = createMutex()
this.mux = mux
/**
* @type {ProsemirrorMapping}
*/
const mapping = new BindMapping()
this.mapping = mapping
const oldDispatch = prosemirror.props.dispatchTransaction || null
/**
* @type {any}
*/
const updatedProps = {
dispatchTransaction: function (tr) {
// TODO: remove
const newState = prosemirror.state.apply(tr)
mux(() => {
updateYFragment(yDomFragment, newState, mapping)
})
if (oldDispatch !== null) {
oldDispatch.call(this, tr)
} else {
prosemirror.updateState(newState)
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()
}
}
}
prosemirror.setProps(updatedProps)
yDomFragment.observeDeep(events => {
if (events.length === 0) {
return
}
mux(() => {
events.forEach(event => {
// recompute node for each parent
// except main node, compute main node in the end
let target = event.target
if (target !== yDomFragment) {
do {
if (target.constructor === Y.XmlElement) {
createNodeFromYElement(target, prosemirror.state.schema, mapping)
}
target = target._parent
} while (target._parent !== yDomFragment)
}
})
const fragmentContent = yDomFragment.toArray().map(t => createNodeIfNotExists(t, prosemirror.state.schema, mapping))
const tr = prosemirror.state.tr.replace(0, prosemirror.state.doc.content.size, new PModel.Slice(new PModel.Fragment(fragmentContent), 0, 0))
const newState = prosemirror.updateState(prosemirror.state.apply(tr))
console.log('state updated', newState, tr)
})
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 = () => {
console.log(y.getAwarenessInfo())
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)
}
}
/**

View File

@ -16,6 +16,28 @@
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>

View File

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

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.ws !== null) {
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

@ -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

@ -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'
/**

View File

@ -3,7 +3,7 @@ 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

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,7 +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 * 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
*/
/**
@ -439,7 +439,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 +468,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: