added utilities to make and recover snapshots

This commit is contained in:
Kevin Jahns 2019-01-29 00:54:58 +01:00
parent 77e479c03b
commit 3a0694c35c
23 changed files with 337 additions and 109 deletions

View File

@ -11,6 +11,8 @@ import { Plugin, PluginKey, EditorState, TextSelection } from 'prosemirror-state
import * as math from '../lib/math.js'
import * as object from '../lib/object.js'
import * as YPos from '../utils/relativePosition.js'
import { isVisible } from '../utils/snapshot.js'
import { simpleDiff } from '../lib/diff.js'
/**
* @typedef {Map<YText | YXmlElement | YXmlFragment, PModel.Node>} ProsemirrorMapping
@ -33,6 +35,9 @@ export const prosemirrorPluginKey = new PluginKey('yjs')
export const prosemirrorPlugin = yXmlFragment => {
let changedInitialContent = false
const plugin = new Plugin({
props: {
editable: (state) => prosemirrorPluginKey.getState(state).snapshot == null
},
key: prosemirrorPluginKey,
state: {
init: (initargs, state) => {
@ -55,7 +60,16 @@ export const prosemirrorPlugin = yXmlFragment => {
if (change !== undefined && change.snapshot !== undefined) {
// snapshot changed, rerender next
setTimeout(() => {
pluginState.binding._renderSnapshot(change.snapshot)
if (change.restore == null) {
pluginState.binding._renderSnapshot(change.snapshot, change.prevSnapshot)
} else {
pluginState.binding._renderSnapshot(change.snapshot, change.snapshot)
// reset to current prosemirror state
delete pluginState.restore
delete pluginState.snapshot
delete pluginState.prevSnapshot
pluginState.binding._prosemirrorChanged(pluginState.binding.prosemirrorView.state.doc)
}
}, 0)
} else if (pluginState.snapshot == null) {
// only apply if no snapshot active
@ -112,7 +126,14 @@ export const cursorPlugin = new Plugin({
const y = ystate.y
const awareness = y.getAwarenessInfo()
const decorations = []
if (ystate.snapshot != null) {
// do not render cursors while snapshot is active
return
}
awareness.forEach((aw, userID) => {
if (userID === y.userID) {
return
}
if (aw.cursor != null) {
let user = aw.user || {}
if (user.color == null) {
@ -154,7 +175,7 @@ export const cursorPlugin = new Plugin({
}
const updateCursorInfo = () => {
const current = y.getLocalAwarenessInfo()
if (view.hasFocus()) {
if (view.hasFocus() && ystate.binding !== null) {
const anchor = absolutePositionToRelativePosition(view.state.selection.anchor, ystate.type, ystate.binding.mapping)
const head = absolutePositionToRelativePosition(view.state.selection.head, ystate.type, ystate.binding.mapping)
if (current.cursor == null || !YPos.equal(current.cursor.anchor, anchor) || !YPos.equal(current.cursor.head, head)) {
@ -321,11 +342,24 @@ export class ProsemirrorBinding {
})
yXmlFragment.observeDeep(this._observeFunction)
}
_renderSnapshot (snapshot) {
_forceRerender () {
this.mapping = new Map()
this.mux(() => {
const fragmentContent = this.type.toArray().map(t => createNodeFromYElement(t, this.prosemirrorView.state.schema, this.mapping)).filter(n => n !== null)
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.dispatch(tr)
})
}
/**
*
* @param {*} snapshot
* @param {*} prevSnapshot
*/
_renderSnapshot (snapshot, prevSnapshot) {
// clear mapping because we are going to rerender
this.mapping = new Map()
this.mux(() => {
const fragmentContent = this.type.toArray(snapshot).map(t => createNodeFromYElement(t, this.prosemirrorView.state.schema, new Map(), snapshot)).filter(n => n !== null)
const fragmentContent = this.type.toArray({ sm: snapshot.sm, ds: prevSnapshot.ds}).map(t => createNodeFromYElement(t, this.prosemirrorView.state.schema, new Map(), snapshot, prevSnapshot)).filter(n => n !== null)
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.dispatch(tr)
})
@ -370,12 +404,13 @@ export class ProsemirrorBinding {
* @param {PModel.Schema} schema
* @param {ProsemirrorMapping} mapping
* @param {HistorySnapshot} [snapshot]
* @param {HistorySnapshot} [prevSnapshot]
* @return {PModel.Node}
*/
export const createNodeIfNotExists = (el, schema, mapping, snapshot) => {
export const createNodeIfNotExists = (el, schema, mapping, snapshot, prevSnapshot) => {
const node = mapping.get(el)
if (node === undefined) {
return createNodeFromYElement(el, schema, mapping, snapshot)
return createNodeFromYElement(el, schema, mapping, snapshot, prevSnapshot)
}
return node
}
@ -385,19 +420,31 @@ export const createNodeIfNotExists = (el, schema, mapping, snapshot) => {
* @param {YXmlElement} el
* @param {PModel.Schema} schema
* @param {ProsemirrorMapping} mapping
* @param {import('../protocols/history.js').HistorySnapshot} snapshot
* @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
* @param {import('../protocols/history.js').HistorySnapshot} [prevSnapshot]
* @return {PModel.Node | null} Returns node if node could be created. Otherwise it deletes the yjs type and returns null
*/
export const createNodeFromYElement = (el, schema, mapping, snapshot) => {
export const createNodeFromYElement = (el, schema, mapping, snapshot, prevSnapshot) => {
let _snapshot = snapshot
let _prevSnapshot = prevSnapshot
if (snapshot !== undefined && prevSnapshot !== undefined) {
if (!isVisible(el, snapshot)) {
// if this element is already rendered as deleted (ychange), then do not render children as deleted
_snapshot = {sm: snapshot.sm, ds: prevSnapshot.ds}
_prevSnapshot = _snapshot
} else if (!isVisible(el, prevSnapshot)) {
_prevSnapshot = _snapshot
}
}
const children = []
el.toArray(snapshot).forEach(type => {
const createChildren = type => {
if (type.constructor === YXmlElement) {
const n = createNodeIfNotExists(type, schema, mapping, snapshot)
const n = createNodeIfNotExists(type, schema, mapping, _snapshot, _prevSnapshot)
if (n !== null) {
children.push(n)
}
} else {
const ns = createTextNodesFromYText(type, schema, mapping, snapshot)
const ns = createTextNodesFromYText(type, schema, mapping, _snapshot, _prevSnapshot)
if (ns !== null) {
ns.forEach(textchild => {
if (textchild !== null) {
@ -406,16 +453,31 @@ export const createNodeFromYElement = (el, schema, mapping, snapshot) => {
})
}
}
})
}
if (snapshot === undefined || prevSnapshot === undefined) {
el.toArray().forEach(createChildren)
} else {
el.toArray({sm: snapshot.sm, ds: prevSnapshot.ds}).forEach(createChildren)
}
let node
try {
node = schema.node(el.nodeName.toLowerCase(), el.getAttributes(snapshot), children)
const attrs = el.getAttributes(_snapshot)
if (snapshot !== undefined) {
if (!isVisible(el, snapshot)) {
attrs.ychange = { user: el._id.user, state: 'removed' }
} else if (!isVisible(el, prevSnapshot)) {
attrs.ychange = { user: el._id.user, state: 'added' }
}
}
node = schema.node(el.nodeName.toLowerCase(), attrs, children)
} catch (e) {
// an error occured while creating the node. This is probably a result because of a concurrent action.
// delete the node and do not push to children
// ignore the node while rendering
/* do not delete anymore
el._y.transact(() => {
el._delete(el._y, true)
})
*/
return null
}
mapping.set(el, node)
@ -427,12 +489,13 @@ export const createNodeFromYElement = (el, schema, mapping, snapshot) => {
* @param {YText} text
* @param {PModel.Schema} schema
* @param {ProsemirrorMapping} mapping
* @param {HistorySnapshot} [snapshot]
* @param {HistorySnapshot} [snapshot]
* @param {HistorySnapshot} [prevSnapshot]
* @return {Array<PModel.Node>}
*/
export const createTextNodesFromYText = (text, schema, mapping, snapshot) => {
export const createTextNodesFromYText = (text, schema, mapping, snapshot, prevSnapshot) => {
const nodes = []
const deltas = text.toDelta(snapshot)
const deltas = text.toDelta(snapshot, prevSnapshot)
try {
for (let i = 0; i < deltas.length; i++) {
const delta = deltas[i]
@ -446,9 +509,11 @@ export const createTextNodesFromYText = (text, schema, mapping, snapshot) => {
mapping.set(text, nodes[0]) // only map to first child, all following children are also considered bound to this type
}
} catch (e) {
/*
text._y.transact(() => {
text._delete(text._y, true)
})
*/
return null
}
return nodes
@ -465,13 +530,17 @@ export const createTypeFromNode = (node, mapping) => {
if (node.isText) {
type = new YText()
const attrs = {}
node.marks.forEach(mark => { attrs[mark.type.name] = mark.attrs })
node.marks.forEach(mark => {
if (mark.type.name !== 'ychange') {
attrs[mark.type.name] = mark.attrs
}
})
type.insert(0, node.text, attrs)
} else {
type = new YXmlElement(node.type.name)
for (let key in node.attrs) {
const val = node.attrs[key]
if (val !== null) {
if (val !== null && key !== 'ychange') {
type.setAttribute(key, val)
}
}
@ -490,7 +559,9 @@ const equalAttrs = (pattrs, yattrs) => {
let eq = keys.length === Object.keys(yattrs).filter(key => yattrs[key] === null).length
for (let i = 0; i < keys.length && eq; i++) {
const key = keys[i]
eq = pattrs[key] === yattrs[key]
const l = pattrs[key]
const r = yattrs[key]
eq = key === 'ychange' || l === r || (typeof l === 'object' && typeof r === 'object' && equalAttrs(l, r))
}
return eq
}
@ -554,7 +625,7 @@ const updateYFragment = (yDomFragment, pContent, mapping) => {
const pAttrs = pContent.attrs
for (let key in pAttrs) {
if (pAttrs[key] !== null) {
if (yDomAttrs[key] !== pAttrs[key]) {
if (yDomAttrs[key] !== pAttrs[key] && key !== 'ychange') {
yDomFragment.setAttribute(key, pAttrs[key])
}
} else {
@ -610,8 +681,23 @@ const updateYFragment = (yDomFragment, pContent, mapping) => {
const rightP = pContent.child(pChildCnt - right - 1)
if (leftY.constructor === YText && leftP.isText) {
if (!equalYTextPText(leftY, leftP)) {
yDomFragment.delete(left, 1)
yDomFragment.insert(left, [createTypeFromNode(leftP, mapping)])
// try to apply diff. Only if attrs don't match, delete insert
// TODO: use a single ytext to hold all following Prosemirror Text nodes
const pattrs = {}
leftP.marks.forEach(mark => {
if (mark.type.name !== 'ychange') {
pattrs[mark.type.name] = mark.attrs
}
})
const delta = leftY.toDelta()
if (delta.length === 1 && delta[0].insert && equalAttrs(pattrs, delta[0].attributes || {})) {
const diff = simpleDiff(delta[0].insert, leftP.text)
leftY.delete(diff.pos, diff.remove)
leftY.insert(diff.pos, diff.insert)
} else {
yDomFragment.delete(left, 1)
yDomFragment.insert(left, [createTypeFromNode(leftP, mapping)])
}
}
left += 1
} else {

View File

@ -1,5 +1,5 @@
import {Plugin} from "prosemirror-state"
import {Plugin} from 'prosemirror-state'
import crel from 'crel'
import * as Y from '../index.js'
import { prosemirrorPluginKey } from '../bindings/prosemirror.js'
@ -7,21 +7,53 @@ import * as encoding from '../lib/encoding.js'
import * as decoding from '../lib/decoding.js'
import * as historyProtocol from '../protocols/history.js'
const niceColors = ['#3cb44b', '#4363d8', '#f58231', '#911eb4', '#46f0f0', '#f032e6', '#bcf60c', '#008080', '#9a6324', '#800000', '#808000', '#000075', '#808080']
const createUserCSS = (userid, username, color = 'rgb(250, 129, 0)', color2 = 'rgba(250, 129, 0, .41)') => `
[ychange_state][ychange_user="${userid}"]:hover::before {
content: "${username}" !important;
background-color: ${color} !important;
}
[ychange_state="added"][ychange_user="${userid}"] {
background-color: ${color2} !important;
}
[ychange_state="removed"][ychange_user="${userid}"] {
color: ${color} !important;
}
`
export const noteHistoryPlugin = new Plugin({
view (editorView) { return new NoteHistoryPlugin(editorView) }
state: {
init (initargs, state) {
return new NoteHistoryPlugin()
},
apply (tr, pluginState) {
return pluginState
}
},
view (editorView) {
const hstate = noteHistoryPlugin.getState(editorView.state)
hstate.init(editorView)
return {
destroy: hstate.destroy.bind(hstate)
}
}
})
const createWrapper = () => {
const wrapper = crel('div', { style: 'display: flex' })
const wrapper = crel('div', { style: 'display: flex;' })
const historyContainer = crel('div', { style: 'align-self: baseline; flex-basis: 250px;', class: 'shared-history' })
wrapper.insertBefore(historyContainer, null)
return { wrapper, historyContainer }
const userStyleContainer = crel('style')
wrapper.insertBefore(userStyleContainer, null)
return { wrapper, historyContainer, userStyleContainer }
}
class NoteHistoryPlugin {
constructor(editorView) {
init (editorView) {
this.editorView = editorView
const { historyContainer, wrapper } = createWrapper()
const { historyContainer, wrapper, userStyleContainer } = createWrapper()
this.userStyleContainer = userStyleContainer
this.wrapper = wrapper
this.historyContainer = historyContainer
const n = editorView.dom.parentNode.parentNode
@ -33,35 +65,94 @@ class NoteHistoryPlugin {
const history = y.define('history', Y.Array)
history.observe(this.render.bind(this))
}
destroy () {
this.wrapper.parentNode.replaceChild(this.wrapper.firstChild, this.wrapper)
const y = prosemirrorPluginKey.getState(this.editorView.state).y
const history = y.define('history', Y.Array)
history.unobserve(this.render)
}
render () {
const y = prosemirrorPluginKey.getState(this.editorView.state).y
const history = y.define('history', Y.Array).toArray()
const fragment = document.createDocumentFragment()
const snapshotBtn = crel('button', { type: 'button' }, ['snapshot'])
snapshotBtn.addEventListener('click', this.snapshot.bind(this))
fragment.insertBefore(snapshotBtn, null)
let _prevSnap = null // empty
snapshotBtn.addEventListener('click', () => {
const awareness = y.getAwarenessInfo()
const userMap = new Map()
const aw = y.getLocalAwarenessInfo()
userMap.set(y.userID, aw.name || 'unknown')
awareness.forEach((a, userID) => {
userMap.set(userID, a.name || 'Unknown')
})
this.snapshot(userMap)
})
history.forEach(buf => {
const decoder = decoding.createDecoder(buf)
const snapshot = historyProtocol.readHistorySnapshot(decoder)
const date = new Date(decoding.readUint32(decoder) * 1000)
const restoreBtn = crel('button', { type: 'button' }, ['restore'])
const a = crel('a', [
'• '+ date.toUTCString()
'• ' + date.toUTCString(), restoreBtn
])
const el = crel('div', [ a ])
let prevSnapshot = _prevSnap // rebind to new variable
restoreBtn.addEventListener('click', event => {
if (prevSnapshot === null) {
prevSnapshot = { ds: snapshot.ds, sm: new Map() }
}
this.editorView.dispatch(this.editorView.state.tr.setMeta(prosemirrorPluginKey, { snapshot, prevSnapshot, restore: true }))
event.stopPropagation()
})
a.addEventListener('click', () => {
console.log('setting snapshot')
this.editorView.dispatch(this.editorView.state.tr.setMeta(prosemirrorPluginKey, { snapshot }))
if (prevSnapshot === null) {
prevSnapshot = { ds: snapshot.ds, sm: new Map() }
}
this.renderSnapshot(snapshot, prevSnapshot)
})
fragment.insertBefore(el, null)
_prevSnap = snapshot
})
this.historyContainer.innerHTML = ''
this.historyContainer.insertBefore(fragment, null)
}
snapshot () {
renderSnapshot (snapshot, prevSnapshot) {
this.editorView.dispatch(this.editorView.state.tr.setMeta(prosemirrorPluginKey, { snapshot, prevSnapshot }))
/**
* @type {Array<string|null>}
*/
let colors = niceColors.slice()
let style = ''
snapshot.userMap.forEach((name, userid) => {
/**
* @type {any}
*/
const randInt = name.split('').map(s => s.charCodeAt(0)).reduce((a, b) => a + b)
let color = null
let i = 0
for (; i < colors.length && color === null; i++) {
color = colors[(randInt + i) % colors.length]
}
if (color === null) {
colors = niceColors.slice()
i = 0
color = colors[randInt % colors.length]
}
colors[randInt % colors.length] = null
style += createUserCSS(userid, name, color, color + '69')
})
this.userStyleContainer.innerHTML = style
}
/**
* @param {Map<number, string>} [updatedUserMap] Maps from userid (yjs model) to account name (e.g. mail address)
*/
snapshot (updatedUserMap = new Map()) {
const y = prosemirrorPluginKey.getState(this.editorView.state).y
const history = y.define('history', Y.Array)
const encoder = encoding.createEncoder()
historyProtocol.writeHistorySnapshot(encoder, y)
historyProtocol.writeHistorySnapshot(encoder, y, updatedUserMap)
encoding.writeUint32(encoder, Math.floor(Date.now() / 1000))
history.push([encoding.toBuffer(encoder)])
}

View File

@ -2,7 +2,7 @@
<html>
<head>
<title>Yjs Prosemirror Example</title>
<link rel=stylesheet href="https://prosemirror.net/css/editor.css">
<link rel=stylesheet href="./prosemirror.css">
<style>
placeholder {
display: inline;
@ -30,6 +30,7 @@
border-left-width: 2px;
border-color: orange;
height: 1em;
word-break: normal;
}
.ProseMirror-yjs-cursor > div {
position: relative;
@ -45,6 +46,36 @@
padding-left: 2px;
padding-right: 2px;
}
[ychange_state] {
position: relative;
}
[ychange_state]:hover::before {
content: attr(ychange_user);
background-color: #fa8100;
position: absolute;
top: -14px;
right: 0;
font-size: 12px;
padding: 0 2px;
border-radius: 3px 3px 0 0;
color: #fdfdfe;
user-select: none;
word-break: normal;
}
*[ychange_state='added'] {
background-color: #fa810069;
}
ychange[ychange_state='removed'] {
color: rgb(250, 129, 0);
text-decoration: line-through;
}
*:not(ychange)[ychange_state='removed'] {
background-color: #ff9494c9;
text-decoration: line-through;
}
img[ychange_state='removed'] {
padding: 2px;
}
</style>
</head>
<body>

View File

@ -6,19 +6,19 @@ import * as conf from './exampleConfig.js'
import { EditorState } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { DOMParser } from 'prosemirror-model'
import { schema } from 'prosemirror-schema-basic'
import { DOMParser, Schema } from 'prosemirror-model'
import { schema } from './prosemirror-schema.js'
import { exampleSetup } from 'prosemirror-example-setup'
import { noteHistoryPlugin } from './prosemirror-history.js'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('prosemirror')
const ydocument = provider.get('prosemirror', { gc: false })
const type = ydocument.define('prosemirror', Y.XmlFragment)
const prosemirrorView = new EditorView(document.querySelector('#editor'), {
state: EditorState.create({
doc: DOMParser.fromSchema(schema).parse(document.querySelector('#content')),
plugins: exampleSetup({schema}).concat([prosemirrorPlugin(type), cursorPlugin, noteHistoryPlugin])
plugins: exampleSetup({schema}).concat([prosemirrorPlugin(type), cursorPlugin /* noteHistoryPlugin */])
})
})

View File

@ -55,4 +55,3 @@ registerStruct(10, YXmlText)
registerStruct(11, YXmlHook)
registerStruct(12, ItemEmbed)
registerStruct(13, ItemBinary)

3
package-lock.json generated
View File

@ -2173,7 +2173,8 @@
"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=="
"integrity": "sha512-VIGY44ERxx8lXVkOEfcB0A49OkjxkQNK+j+fHvoLy7GsGX1KKgAaQ+p9N0YgvQXu+X+ryUWGDeLx/fSI+w7+eg==",
"dev": true
},
"cross-spawn": {
"version": "5.1.0",

View File

@ -9,7 +9,7 @@ const mux = Y.createMutex()
/*
* Improves the uniqueness of timestamps.
* We gamble with the fact that users won't create more than 10000 changes on a single document
* We gamble with the fact that users won't create more than 10000 changes on a single document
* within one millisecond (also assuming clock works correctly).
*/
let timestampIterator = 0
@ -22,13 +22,13 @@ const getNextTimestamp = () => {
}
/**
* @param {string} docName
* @param {string} docName
* @return {string}
*/
const generateEntryKey = docName => `${docName}#${getNextTimestamp()}`
/**
*
*
* @param {any} db
* @param {string} docName
* @param {Uint8Array | ArrayBuffer} buf
@ -44,21 +44,21 @@ const readEntry = (arr, ydocument) => mux(() =>
)
/**
* @param {any} db
* @param {string} docName
* @param {Y.Y} ydocument
* @param {any} db
* @param {string} docName
* @param {Y.Y} ydocument
*/
const loadFromPersistence = (db, docName, ydocument) => new Promise((resolve, reject)=>
const loadFromPersistence = (db, docName, ydocument) => new Promise((resolve, reject) =>
db.createReadStream({
gte: `${docName}#`,
lte: `${docName}#Z`,
keys: false,
values: true
})
.on('data', data => readEntry(data, ydocument))
.on('error', reject)
.on('end', resolve)
.on('close', resolve)
.on('data', data => readEntry(data, ydocument))
.on('error', reject)
.on('end', resolve)
.on('close', resolve)
)
const persistState = (db, docName, ydocument) => {
@ -68,9 +68,9 @@ const persistState = (db, docName, ydocument) => {
const entryPromise = db.put(entryKey, Y.encoding.toBuffer(encoder))
const delOps = []
return new Promise((resolve, reject) => db.createKeyStream({
gte: `${docName}#`,
lt: entryKey,
})
gte: `${docName}#`,
lt: entryKey
})
.on('data', key => delOps.push({ type: 'del', key }))
.on('error', reject)
.on('end', resolve)
@ -92,7 +92,7 @@ exports.LevelDbPersistence = class LevelDbPersistence {
* Retrieve all data from LevelDB and automatically persist all document updates to leveldb.
*
* @param {string} docName
* @param {Y.Y} ydocument
* @param {Y.Y} ydocument
*/
bindState (docName, ydocument) {
// write all updates received from other clients
@ -116,10 +116,10 @@ exports.LevelDbPersistence = class LevelDbPersistence {
* Write current state to persistence layer. Deletes all entries that were made before.
* Call this method at any time - the recommended time to call this method is before the ydocument is destroyed.
*
* @param {string} docName
* @param {Y.Y} ydocument
* @param {string} docName
* @param {Y.Y} ydocument
*/
writeState (docName, ydocument) {
return persistState(this.db, docName, ydocument)
}
}
}

View File

@ -40,24 +40,22 @@ export const readUsersStateChange = (decoder, y) => {
const userID = decoding.readVarUint(decoder)
const clock = decoding.readVarUint(decoder)
const state = JSON.parse(decoding.readVarString(decoder))
if (userID !== y.userID) {
const uClock = y.awarenessClock.get(userID) || 0
y.awarenessClock.set(userID, clock)
if (state === null) {
// only write if clock increases. cannot overwrite
if (y.awareness.has(userID) && uClock < clock) {
y.awareness.delete(userID)
removed.push(userID)
}
} else if (uClock <= clock) { // allow to overwrite (e.g. when client was on, then offline)
if (y.awareness.has(userID)) {
updated.push(userID)
} else {
added.push(userID)
}
y.awareness.set(userID, state)
y.awarenessClock.set(userID, clock)
const uClock = y.awarenessClock.get(userID) || 0
y.awarenessClock.set(userID, clock)
if (state === null) {
// only write if clock increases. cannot overwrite
if (y.awareness.has(userID) && uClock < clock) {
y.awareness.delete(userID)
removed.push(userID)
}
} else if (uClock <= clock) { // allow to overwrite (e.g. when client was on, then offline)
if (y.awareness.has(userID)) {
updated.push(userID)
} else {
added.push(userID)
}
y.awareness.set(userID, state)
y.awarenessClock.set(userID, clock)
}
}
if (added.length > 0 || updated.length > 0 || removed.length > 0) {

View File

@ -1,5 +1,4 @@
import * as encoding from '../lib/encoding.js'
import * as decoding from '../lib/decoding.js'
import { Y } from '../utils/Y.js' // eslint-disable-line
@ -10,24 +9,38 @@ import { writeStateMap, readStateMap } from '../utils/StateStore.js'
* @typedef {Object} HistorySnapshot
* @property {DeleteStore} HistorySnapshot.ds
* @property {Map<number,number>} HistorySnapshot.sm
* @property {Map<number,string>} HistorySnapshot.userMap
*/
/**
* @param {encoding.Encoder} encoder
* @param {Y} y
* @param {Map<number, string>} userMap
*/
export const writeHistorySnapshot = (encoder, y) => {
export const writeHistorySnapshot = (encoder, y, userMap) => {
writeDeleteStore(encoder, y.ds)
writeStateMap(encoder, y.ss.state)
encoding.writeVarUint(encoder, userMap.size)
userMap.forEach((accountname, userid) => {
encoding.writeVarUint(encoder, userid)
encoding.writeVarString(encoder, accountname)
})
}
/**
*
*
* @param {decoding.Decoder} decoder
* @return {HistorySnapshot}
*/
export const readHistorySnapshot = (decoder) => {
export const readHistorySnapshot = decoder => {
const ds = readFreshDeleteStore(decoder)
const sm = readStateMap(decoder)
return { ds, sm }
}
const size = decoding.readVarUint(decoder)
const userMap = new Map()
for (let i = 0; i < size; i++) {
const userid = decoding.readVarUint(decoder)
const accountname = decoding.readVarString(decoder)
userMap.set(userid, accountname)
}
return { ds, sm, userMap }
}

View File

@ -97,8 +97,8 @@ const broadcastUpdate = (y, transaction) => {
}
class WebsocketsSharedDocument extends Y.Y {
constructor (url) {
super()
constructor (url, opts) {
super(opts)
this.url = url
this.wsconnected = false
this.mux = Y.createMutex()
@ -112,7 +112,7 @@ class WebsocketsSharedDocument extends Y.Y {
const encoder = readMessage(this, data) // already muxed
this.mux(() => {
if (Y.encoding.length(encoder) > 1) {
bc.publish(url, Y.encoding.toBuffer(encoder))
bc.publish(url, Y.encoding.toBuffer(encoder))
}
})
}
@ -173,10 +173,10 @@ export class WebsocketProvider {
* @param {string} name
* @return {WebsocketsSharedDocument}
*/
get (name) {
get (name, opts) {
let doc = this.docs.get(name)
if (doc === undefined) {
doc = new WebsocketsSharedDocument(this.url + name)
doc = new WebsocketsSharedDocument(this.url + name, opts)
}
return doc
}

View File

@ -12,6 +12,8 @@ const http = require('http')
const port = process.env.PORT || 1234
// disable gc when using snapshots!
const gcEnabled = process.env.GC !== 'false' && process.env.GC !== '0'
const persistenceDir = process.env.YPERSISTENCE
let persistence = null
if (typeof persistenceDir === 'string') {
@ -44,7 +46,7 @@ const afterTransaction = (doc, transaction) => {
class WSSharedDoc extends Y.Y {
constructor () {
super({ gc: true })
super({ gc: gcEnabled })
this.mux = Y.createMutex()
/**
* Maps from conn to set of controlled user ids. Delete all user ids from awareness when this conn is closed
@ -150,4 +152,4 @@ server.on('upgrade', (request, socket, head) => {
server.listen(port)
console.log('running on port', port)
console.log('running on port', port)

Binary file not shown.

Binary file not shown.

View File

@ -1 +0,0 @@
MANIFEST-000004

View File

View File

@ -1,5 +0,0 @@
2018/12/22-13:33:44.376775 70000c740000 Recovering log #3
2018/12/22-13:33:44.377203 70000c740000 Level-0 table #5: started
2018/12/22-13:33:44.379580 70000c740000 Level-0 table #5: 4834 bytes OK
2018/12/22-13:33:44.380496 70000c740000 Delete type=0 #3
2018/12/22-13:33:44.380670 70000c740000 Delete type=3 #2

View File

@ -1 +0,0 @@
2018/12/22-13:23:54.104944 700007836000 Delete type=3 #1

Binary file not shown.

View File

@ -8,7 +8,7 @@ import { ItemJSON } from '../structs/ItemJSON.js'
import * as stringify from '../utils/structStringify.js'
import { YEvent } from '../utils/YEvent.js'
import { ItemBinary } from '../structs/ItemBinary.js'
import { isVisible } from '../utils/snapshot.js';
import { isVisible } from '../utils/snapshot.js'
/**
* Event that describes the changes on a YMap.
@ -82,11 +82,11 @@ export class YMap extends Type {
}
}
} else {
for (let key in this._map) {
if (this.has(key, snapshot)) {
this._map.forEach((_, key) => {
if (YMap.prototype.has.call(this, key, snapshot)) {
keys.push(key)
}
}
})
}
return keys
}

View File

@ -572,11 +572,12 @@ export class YText extends YArray {
* Returns the Delta representation of this YText type.
*
* @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
* @param {import('../protocols/history.js').HistorySnapshot} [prevSnapshot]
* @return {Delta} The Delta representation of this type.
*
* @public
*/
toDelta (snapshot) {
toDelta (snapshot, prevSnapshot) {
let ops = []
let currentAttributes = new Map()
let str = ''
@ -602,9 +603,24 @@ export class YText extends YArray {
}
}
while (n !== null) {
if (isVisible(n, snapshot)) {
if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) {
switch (n.constructor) {
case ItemString:
const cur = currentAttributes.get('ychange')
if (snapshot !== undefined && !isVisible(n, snapshot)) {
if (cur === undefined || cur.user !== n._id.user || cur.state !== 'removed') {
packStr()
currentAttributes.set('ychange', { user: n._id.user, state: 'removed' })
}
} else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) {
if (cur === undefined || cur.user !== n._id.user || cur.state !== 'added') {
packStr()
currentAttributes.set('ychange', { user: n._id.user, state: 'added' })
}
} else if (cur !== undefined) {
packStr()
currentAttributes.delete('ychange')
}
str += n._content
break
case ItemFormat:

View File

@ -326,9 +326,9 @@ export class YXmlElement extends YXmlFragment {
}
}
} else {
for (let key in this._map) {
return YMap.prototype.get.call(this, key, snapshot)
}
YMap.prototype.keys.call(this, snapshot).forEach(key => {
obj[key] = YMap.prototype.get.call(this, key, snapshot)
})
}
return obj
}

View File

@ -92,7 +92,6 @@ export class DeleteStore extends Tree {
}
}
/**
* Stringifies a message-encoded Delete Set.
*

View File

@ -1,8 +1,7 @@
/**
*
*
* @param {Item} item
* @param {import("../protocols/history").HistorySnapshot} [snapshot]
*/
export const isVisible = (item, snapshot) => snapshot === undefined ? !item._deleted : (snapshot.sm.has(item._id.user) && snapshot.sm.get(item._id.user) > item._id.clock && !snapshot.ds.isDeleted(item._id))
export const isVisible = (item, snapshot) => snapshot === undefined ? !item._deleted : (snapshot.sm.has(item._id.user) && snapshot.sm.get(item._id.user) > item._id.clock && !snapshot.ds.isDeleted(item._id))