diff --git a/bindings/prosemirror.js b/bindings/prosemirror.js index 984a7dcc..7bd15c72 100644 --- a/bindings/prosemirror.js +++ b/bindings/prosemirror.js @@ -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} 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} */ -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 { diff --git a/examples/prosemirror-history.js b/examples/prosemirror-history.js index c3da82a9..36cffc7b 100644 --- a/examples/prosemirror-history.js +++ b/examples/prosemirror-history.js @@ -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} + */ + 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} [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)]) } diff --git a/examples/prosemirror.html b/examples/prosemirror.html index 2aa4fefb..2c15392a 100644 --- a/examples/prosemirror.html +++ b/examples/prosemirror.html @@ -2,7 +2,7 @@ Yjs Prosemirror Example - + diff --git a/examples/prosemirror.js b/examples/prosemirror.js index f546b4a4..0017a0c5 100644 --- a/examples/prosemirror.js +++ b/examples/prosemirror.js @@ -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 */]) }) }) diff --git a/index.js b/index.js index 09d56b2b..7ed6866e 100644 --- a/index.js +++ b/index.js @@ -55,4 +55,3 @@ registerStruct(10, YXmlText) registerStruct(11, YXmlHook) registerStruct(12, ItemEmbed) registerStruct(13, ItemBinary) - diff --git a/package-lock.json b/package-lock.json index 9e8bd726..a79f2e8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/persistence/leveldb.js b/persistence/leveldb.js index 86786596..5095922b 100644 --- a/persistence/leveldb.js +++ b/persistence/leveldb.js @@ -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) } -} \ No newline at end of file +} diff --git a/protocols/awareness.js b/protocols/awareness.js index 4414d0f8..9e935f56 100644 --- a/protocols/awareness.js +++ b/protocols/awareness.js @@ -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) { diff --git a/protocols/history.js b/protocols/history.js index c74e8531..8ee5c603 100644 --- a/protocols/history.js +++ b/protocols/history.js @@ -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} HistorySnapshot.sm + * @property {Map} HistorySnapshot.userMap */ /** * @param {encoding.Encoder} encoder * @param {Y} y + * @param {Map} 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 } -} \ No newline at end of file + 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 } +} diff --git a/provider/websocket/WebSocketProvider.js b/provider/websocket/WebSocketProvider.js index f10f1ce9..b40cdde6 100644 --- a/provider/websocket/WebSocketProvider.js +++ b/provider/websocket/WebSocketProvider.js @@ -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 } diff --git a/provider/websocket/server.js b/provider/websocket/server.js index 77e9842c..9c422a5f 100644 --- a/provider/websocket/server.js +++ b/provider/websocket/server.js @@ -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) \ No newline at end of file +console.log('running on port', port) diff --git a/tmp/000005.ldb b/tmp/000005.ldb deleted file mode 100644 index f4c6f397..00000000 Binary files a/tmp/000005.ldb and /dev/null differ diff --git a/tmp/000006.log b/tmp/000006.log deleted file mode 100644 index 9f199c34..00000000 Binary files a/tmp/000006.log and /dev/null differ diff --git a/tmp/CURRENT b/tmp/CURRENT deleted file mode 100644 index cacca757..00000000 --- a/tmp/CURRENT +++ /dev/null @@ -1 +0,0 @@ -MANIFEST-000004 diff --git a/tmp/LOCK b/tmp/LOCK deleted file mode 100644 index e69de29b..00000000 diff --git a/tmp/LOG b/tmp/LOG deleted file mode 100644 index 5e3bd35a..00000000 --- a/tmp/LOG +++ /dev/null @@ -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 diff --git a/tmp/LOG.old b/tmp/LOG.old deleted file mode 100644 index 19ad4ad9..00000000 --- a/tmp/LOG.old +++ /dev/null @@ -1 +0,0 @@ -2018/12/22-13:23:54.104944 700007836000 Delete type=3 #1 diff --git a/tmp/MANIFEST-000004 b/tmp/MANIFEST-000004 deleted file mode 100644 index 2099f35d..00000000 Binary files a/tmp/MANIFEST-000004 and /dev/null differ diff --git a/types/YMap.js b/types/YMap.js index c05fcb3d..2bf9e905 100644 --- a/types/YMap.js +++ b/types/YMap.js @@ -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 } diff --git a/types/YText.js b/types/YText.js index d5f32ddc..b49f7037 100644 --- a/types/YText.js +++ b/types/YText.js @@ -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: diff --git a/types/YXmlElement.js b/types/YXmlElement.js index 72131b9f..8ede1092 100644 --- a/types/YXmlElement.js +++ b/types/YXmlElement.js @@ -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 } diff --git a/utils/DeleteStore.js b/utils/DeleteStore.js index 9b44bc2f..83fa657b 100644 --- a/utils/DeleteStore.js +++ b/utils/DeleteStore.js @@ -92,7 +92,6 @@ export class DeleteStore extends Tree { } } - /** * Stringifies a message-encoded Delete Set. * diff --git a/utils/snapshot.js b/utils/snapshot.js index 08b5c7a6..ce121c7f 100644 --- a/utils/snapshot.js +++ b/utils/snapshot.js @@ -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)) \ No newline at end of file +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))