added utilities to make and recover snapshots
This commit is contained in:
parent
77e479c03b
commit
3a0694c35c
@ -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 {
|
||||
|
@ -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)])
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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 */])
|
||||
})
|
||||
})
|
||||
|
||||
|
1
index.js
1
index.js
@ -55,4 +55,3 @@ registerStruct(10, YXmlText)
|
||||
registerStruct(11, YXmlHook)
|
||||
registerStruct(12, ItemEmbed)
|
||||
registerStruct(13, ItemBinary)
|
||||
|
||||
|
3
package-lock.json
generated
3
package-lock.json
generated
@ -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",
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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 }
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
BIN
tmp/000005.ldb
BIN
tmp/000005.ldb
Binary file not shown.
BIN
tmp/000006.log
BIN
tmp/000006.log
Binary file not shown.
@ -1 +0,0 @@
|
||||
MANIFEST-000004
|
5
tmp/LOG
5
tmp/LOG
@ -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
|
@ -1 +0,0 @@
|
||||
2018/12/22-13:23:54.104944 700007836000 Delete type=3 #1
|
Binary file not shown.
@ -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
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -92,7 +92,6 @@ export class DeleteStore extends Tree {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Stringifies a message-encoded Delete Set.
|
||||
*
|
||||
|
@ -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))
|
||||
|
Loading…
x
Reference in New Issue
Block a user