From 582095e5a30acf90d0c73f39230b71908216b423 Mon Sep 17 00:00:00 2001 From: Kevin Jahns Date: Mon, 3 Dec 2018 17:09:00 +0100 Subject: [PATCH] improved granularity of prosemirror binding --- bindings/prosemirror.js | 475 +++++++++++++++++++----- examples/prosemirror.html | 7 + lib/globals.js | 2 - lib/math.js | 24 ++ lib/object.js | 14 + package.json | 4 +- provider/websocket/WebSocketProvider.js | 2 +- provider/websocket/server.js | 2 +- provider/ydb/idbactions.js | 2 +- rollup.config.js | 25 +- structs/GC.js | 4 + structs/Item.js | 26 +- structs/Type.js | 11 + types/YArray.js | 10 +- types/YText.js | 4 + utils/EventHandler.js | 4 +- utils/Y.js | 10 +- utils/relativePosition.js | 19 +- 18 files changed, 519 insertions(+), 126 deletions(-) create mode 100644 lib/object.js diff --git a/bindings/prosemirror.js b/bindings/prosemirror.js index 4aa08725..8ff2b0a0 100644 --- a/bindings/prosemirror.js +++ b/bindings/prosemirror.js @@ -2,16 +2,18 @@ * @module bindings/prosemirror */ -import { BindMapping } from '../utils/BindMapping.js' import { YText } from '../types/YText.js' // eslint-disable-line import { YXmlElement, YXmlFragment } from '../types/YXmlElement.js' // eslint-disable-line import { createMutex } from '../lib/mutex.js' import * as PModel from 'prosemirror-model' import { EditorView, Decoration, DecorationSet } from 'prosemirror-view' // eslint-disable-line -import { Plugin, PluginKey, EditorState } from 'prosemirror-state' // eslint-disable-line +import { Plugin, PluginKey, EditorState, TextSelection } from 'prosemirror-state' // eslint-disable-line +import * as math from '../lib/math.js' +import * as object from '../lib/object.js' +import * as YPos from '../utils/relativePosition.js' /** - * @typedef {BindMapping} ProsemirrorMapping + * @typedef {Map} ProsemirrorMapping */ /** @@ -34,6 +36,7 @@ export const prosemirrorPlugin = yXmlFragment => { y: yXmlFragment._y, binding: null } + let changedInitialContent = false const plugin = new Plugin({ key: prosemirrorPluginKey, state: { @@ -41,6 +44,11 @@ export const prosemirrorPlugin = yXmlFragment => { return pluginState }, apply: (tr, pluginState) => { + // update Yjs state when apply is called. We need to do this here to compute the correct cursor decorations with the cursor plugin + if (pluginState.binding !== null && (changedInitialContent || tr.doc.content.size > 4)) { + changedInitialContent = true + pluginState.binding._prosemirrorChanged(tr.doc) + } return pluginState } }, @@ -49,7 +57,10 @@ export const prosemirrorPlugin = yXmlFragment => { pluginState.binding = binding return { update: () => { - binding._prosemirrorChanged() + if (changedInitialContent || view.state.doc.content.size > 4) { + changedInitialContent = true + binding._prosemirrorChanged(view.state.doc) + } }, destroy: () => { binding.destroy() @@ -61,7 +72,7 @@ export const prosemirrorPlugin = yXmlFragment => { } /** - * The unique prosemirror plugin key for cursorPlugin. + * The unique prosemirror plugin key for cursorPlugin.type * * @public */ @@ -77,44 +88,61 @@ export const cursorPlugin = new Plugin({ key: cursorPluginKey, props: { decorations: state => { - const y = prosemirrorPluginKey.getState(state).y + const ystate = prosemirrorPluginKey.getState(state) + const y = ystate.y const awareness = y.getAwarenessInfo() const decorations = [] - awareness.forEach((state, userID) => { - if (state.cursor != null) { + awareness.forEach((aw, userID) => { + if (aw.cursor != null) { const username = `User: ${userID}` - decorations.push(Decoration.widget(state.cursor.from, () => { - const cursor = document.createElement('span') - cursor.classList.add('ProseMirror-yjs-cursor') - const user = document.createElement('div') - user.insertBefore(document.createTextNode(username), null) - cursor.insertBefore(user, null) - return cursor - }, { key: username })) - decorations.push(Decoration.inline(state.cursor.from, state.cursor.to, { style: 'background-color: #ffa50070' })) + let anchor = relativePositionToAbsolutePosition(ystate.type, aw.cursor.anchor || null, ystate.binding.mapping) + let head = relativePositionToAbsolutePosition(ystate.type, aw.cursor.head || null, ystate.binding.mapping) + if (anchor !== null && head !== null) { + let maxsize = math.max(state.doc.content.size - 1, 0) + anchor = math.min(anchor, maxsize) + head = math.min(head, maxsize) + decorations.push(Decoration.widget(head, () => { + const cursor = document.createElement('span') + cursor.classList.add('ProseMirror-yjs-cursor') + const user = document.createElement('div') + user.insertBefore(document.createTextNode(username), null) + cursor.insertBefore(user, null) + return cursor + }, { key: username })) + const from = math.min(anchor, head) + const to = math.max(anchor, head) + decorations.push(Decoration.inline(from, to, { style: 'background-color: #ffa50070' })) + } } }) return DecorationSet.create(state.doc, decorations) } }, view: view => { - const y = prosemirrorPluginKey.getState(view.state).y + const ystate = prosemirrorPluginKey.getState(view.state) + const y = ystate.y const awarenessListener = () => { view.updateState(view.state) } - y.on('awareness', awarenessListener) - return { - update: () => { - const y = prosemirrorPluginKey.getState(view.state).y - const from = view.state.selection.from - const to = view.state.selection.to - const current = y.getLocalAwarenessInfo() - if (current.cursor == null || current.cursor.to !== to || current.cursor.from !== from) { + const updateCursorInfo = () => { + const current = y.getLocalAwarenessInfo() + if (view.hasFocus()) { + 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)) { y.setAwarenessField('cursor', { - from, to + anchor, head }) } - }, + } else if (current.cursor !== null) { + y.setAwarenessField('cursor', null) + } + } + y.on('awareness', awarenessListener) + view.dom.addEventListener('focusin', updateCursorInfo) + view.dom.addEventListener('focusout', updateCursorInfo) + return { + update: updateCursorInfo, destroy: () => { const y = prosemirrorPluginKey.getState(view.state).y y.setAwarenessField('cursor', null) @@ -124,6 +152,115 @@ export const cursorPlugin = new Plugin({ } }) +/** + * Transforms a Prosemirror based absolute position to a Yjs based relative position. + * + * @param {number} pos + * @param {YXmlFragment} type + * @param {ProsemirrorMapping} mapping + * @return {any} relative position + */ +export const absolutePositionToRelativePosition = (pos, type, mapping) => { + if (pos === 0) { + return YPos.getRelativePosition(type, 0) + } + let n = type._first + if (n !== null) { + while (type !== n) { + const pNodeSize = (mapping.get(n) || { nodeSize: 0 }).nodeSize + if (n.constructor === YText) { + if (n.length >= pos) { + return YPos.getRelativePosition(n, pos) + } else { + pos -= n.length + } + if (n._next !== null) { + n = n._next + } else { + do { + n = n._parent + pos-- + } while (n._next === null && n !== type) + if (n !== type) { + n = n._next + } + } + } else if (n._first !== null && pos < pNodeSize) { + n = n._first + pos-- + } else { + if (pos === 1 && n.length === 0 && pNodeSize > 1) { + // edge case, should end in this paragraph + return ['endof', n._id.user, n._id.clock, null, null] + } + pos -= pNodeSize + if (n._next !== null) { + n = n._next + } else { + if (pos === 0) { + n = n._parent + return ['endof', n._id.user, n._id.clock || null, n._id.name || null, n._id.type || null] + } + do { + n = n._parent + pos-- + } while (n._next === null && n !== type) + if (n !== type) { + n = n._next + } + } + } + if (pos === 0 && n.constructor !== YText && n !== type) { // TODO: set to <= 0 + return [n._id.user, n._id.clock] + } + } + } + return YPos.getRelativePosition(type, type.length) +} + +/** + * @param {YXmlFragment} yDoc Top level type that is bound to pView + * @param {any} relPos Encoded Yjs based relative position + * @param {ProsemirrorMapping} mapping + */ +export const relativePositionToAbsolutePosition = (yDoc, relPos, mapping) => { + const decodedPos = YPos.fromRelativePosition(yDoc._y, relPos) + if (decodedPos === null) { + return null + } + let type = decodedPos.type + let pos = 0 + if (type.constructor === YText) { + pos = decodedPos.offset + } else if (!type._deleted) { + let n = type._first + let i = 0 + while (i < type.length && i < decodedPos.offset && n !== null) { + i++ + pos += mapping.get(n).nodeSize + n = n._next + } + pos += 1 // increase because we go out of n + } + while (type !== yDoc) { + const parent = type._parent + if (!parent._deleted) { + pos += 1 // the start tag + let n = parent._first + // now iterate until we found type + while (n !== null) { + if (n === type) { + break + } + pos += mapping.get(n).nodeSize + n = n._next + } + } + type = parent + } + return pos - 1 // we don't count the most outer tag, because it is a fragment +} + /** * Binding for prosemirror. * @@ -141,36 +278,47 @@ export class ProsemirrorBinding { /** * @type {ProsemirrorMapping} */ - this.mapping = new BindMapping() + this.mapping = new Map() this._observeFunction = this._typeChanged.bind(this) + this.y = yXmlFragment._y + /** + * current selection as relative positions in the Yjs model + */ + this._relSelection = null + this.y.on('beforeTransaction', e => { + this._relSelection = { + anchor: absolutePositionToRelativePosition(this.prosemirrorView.state.selection.anchor, yXmlFragment, this.mapping), + head: absolutePositionToRelativePosition(this.prosemirrorView.state.selection.head, yXmlFragment, this.mapping) + } + }) yXmlFragment.observeDeep(this._observeFunction) } - _typeChanged (events) { + _typeChanged (events, transaction) { if (events.length === 0) { return } + console.info('new types:', transaction.newTypes.size, 'deleted types:', transaction.deletedStructs.size, transaction.newTypes, transaction.deletedStructs) this.mux(() => { - events.forEach(event => { - // recompute node for each parent - // except main node, compute main node in the end - let target = event.target - if (target !== this.type) { - do { - if (target.constructor === YXmlElement) { - createNodeFromYElement(target, this.prosemirrorView.state.schema, this.mapping) - } - target = target._parent - } while (target._parent !== this.type) - } - }) - const fragmentContent = this.type.toArray().map(t => createNodeIfNotExists(t, this.prosemirrorView.state.schema, this.mapping)) + const delStruct = (_, struct) => this.mapping.delete(struct) + transaction.deletedStructs.forEach(struct => this.mapping.delete(struct)) + transaction.changedTypes.forEach(delStruct) + transaction.changedParentTypes.forEach(delStruct) + const fragmentContent = this.type.toArray().map(t => createNodeIfNotExists(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)) + const relSel = this._relSelection + if (relSel !== null && relSel.anchor !== null && relSel.head !== null) { + const anchor = relativePositionToAbsolutePosition(this.type, relSel.anchor, this.mapping) + const head = relativePositionToAbsolutePosition(this.type, relSel.head, this.mapping) + if (anchor !== null && head !== null) { + tr.setSelection(TextSelection.create(tr.doc, anchor, head)) + } + } this.prosemirrorView.updateState(this.prosemirrorView.state.apply(tr)) }) } - _prosemirrorChanged () { + _prosemirrorChanged (doc) { this.mux(() => { - updateYFragment(this.type, this.prosemirrorView.state, this.mapping) + updateYFragment(this.type, doc.content, this.mapping) }) } destroy () { @@ -179,14 +327,14 @@ export class ProsemirrorBinding { } /** - * @private - * @param {Y.XmlElement} el + * @privateMapping + * @param {YXmlElement} el * @param {PModel.Schema} schema * @param {ProsemirrorMapping} mapping * @return {PModel.Node} */ export const createNodeIfNotExists = (el, schema, mapping) => { - const node = mapping.getY(el) + const node = mapping.get(el) if (node === undefined) { return createNodeFromYElement(el, schema, mapping) } @@ -195,28 +343,48 @@ export const createNodeIfNotExists = (el, schema, mapping) => { /** * @private - * @param {Y.XmlElement} el + * @param {YXmlElement} el * @param {PModel.Schema} schema * @param {ProsemirrorMapping} mapping - * @return {PModel.Node} + * @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) => { const children = [] el.toArray().forEach(type => { if (type.constructor === YXmlElement) { - children.push(createNodeIfNotExists(type, schema, mapping)) + const n = createNodeIfNotExists(type, schema, mapping) + if (n !== null) { + children.push(n) + } } else { - children.concat(createTextNodesFromYText(type, schema, mapping)).forEach(textchild => children.push(textchild)) + const ns = createTextNodesFromYText(type, schema, mapping) + if (ns !== null) { + ns.forEach(textchild => { + if (textchild !== null) { + children.push(textchild) + } + }) + } } }) - const node = schema.node(el.nodeName.toLowerCase(), el.getAttributes(), el.toArray().map(t => createNodeIfNotExists(t, schema, mapping))) - mapping.bind(el, node) + let node + try { + node = schema.node(el.nodeName.toLowerCase(), el.getAttributes(), 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 + el._y.transact(() => { + el._delete(el._y, true) + }) + return null + } + mapping.set(el, node) return node } /** * @private - * @param {Y.Text} text + * @param {YText} text * @param {PModel.Schema} schema * @param {ProsemirrorMapping} mapping * @return {Array} @@ -224,16 +392,23 @@ export const createNodeFromYElement = (el, schema, mapping) => { export const createTextNodesFromYText = (text, schema, mapping) => { const nodes = [] const deltas = text.toDelta() - for (let i = 0; i < deltas.length; i++) { - const delta = deltas[i] - const marks = [] - for (let markName in delta.attributes) { - marks.push(schema.mark(markName, delta.attributes[markName])) + try { + for (let i = 0; i < deltas.length; i++) { + const delta = deltas[i] + const marks = [] + for (let markName in delta.attributes) { + marks.push(schema.mark(markName, delta.attributes[markName])) + } + nodes.push(schema.text(delta.insert, marks)) } - nodes.push(schema.text(delta.insert, marks)) - } - if (nodes.length > 0) { - mapping.bind(text, nodes[0]) // only map to first child, all following children are also considered bound to this type + if (nodes.length > 0) { + 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 } @@ -256,44 +431,176 @@ export const createTypeFromNode = (node, mapping) => { for (let key in node.attrs) { type.setAttribute(key, node.attrs[key]) } - type.insert(0, node.content.content.map(node => createTypeFromNode(node, mapping))) + const ins = [] + for (let i = 0; i < node.childCount; i++) { + ins.push(createTypeFromNode(node.child(i), mapping)) + } + type.insert(0, ins) } - mapping.bind(type, node) + mapping.set(type, node) return type } +const equalYTextPText = (ytext, ptext) => { + const d = ytext.toDelta()[0] + return d.insert === ptext.text && object.keys(d.attributes || {}).length === ptext.marks.length && ptext.marks.every(mark => object.equalFlat(d.attributes[mark.type.name], mark.attrs)) +} + +const equalYTypePNode = (ytype, pnode) => + ytype.constructor === YText + ? equalYTextPText(ytype, pnode) + : (matchNodeName(ytype, pnode) && ytype.length === pnode.childCount && object.equalFlat(ytype.getAttributes(), pnode.attrs) && ytype.toArray().every((ychild, i) => equalYTypePNode(ychild, pnode.child(i)))) + +const computeChildEqualityFactor = (ytype, pnode, mapping) => { + const yChildren = ytype.toArray() + const pChildCnt = pnode.childCount + const yChildCnt = yChildren.length + const minCnt = math.min(yChildCnt, pChildCnt) + let left = 0 + let right = 0 + let foundMappedChild = false + for (; left < minCnt; left++) { + const leftY = yChildren[left] + const leftP = pnode.child(left) + if (mapping.get(leftY) === leftP) { + foundMappedChild = true// definite (good) match! + } else if (!equalYTypePNode(leftY, leftP)) { + break + } + } + for (; left + right < minCnt; right++) { + const rightY = yChildren[yChildCnt - right - 1] + const rightP = pnode.child(pChildCnt - right - 1) + if (mapping.get(rightY) !== rightP) { + foundMappedChild = true + } else if (!equalYTypePNode(rightP, rightP)) { + break + } + } + return { + equalityFactor: left + right, + foundMappedChild + } +} + /** * @private * @param {YXmlFragment} yDomFragment - * @param {EditorState} state - * @param {BindMapping} mapping + * @param {PModel.Node} pContent + * @param {ProsemirrorMapping} mapping */ -const updateYFragment = (yDomFragment, state, mapping) => { - const pChildCnt = state.doc.content.childCount +const updateYFragment = (yDomFragment, pContent, mapping) => { + if (yDomFragment instanceof YXmlElement && yDomFragment.nodeName.toLowerCase() !== pContent.type.name) { + throw new Error('node name mismatch!') + } + mapping.set(yDomFragment, pContent) + // update attributes + if (yDomFragment instanceof YXmlElement) { + const yDomAttrs = yDomFragment.getAttributes() + for (let key in pContent.attrs) { + if (yDomAttrs[key] !== pContent.attrs[key]) { + yDomFragment.setAttribute(key, pContent.attrs[key]) + } + } + for (let key in yDomAttrs) { + if (yDomAttrs[key] === undefined) { + yDomFragment.removeAttribute(key) + } + } + } + // update children + const pChildCnt = pContent.childCount const yChildren = yDomFragment.toArray() const yChildCnt = yChildren.length - const minCnt = pChildCnt < yChildCnt ? pChildCnt : yChildCnt + const minCnt = math.min(pChildCnt, yChildCnt) let left = 0 let right = 0 // find number of matching elements from left for (;left < minCnt; left++) { - if (state.doc.content.child(left) !== mapping.getY(yChildren[left])) { - break + const leftY = yChildren[left] + const leftP = pContent.child(left) + if (mapping.get(leftY) !== leftP) { + if (equalYTypePNode(leftY, leftP)) { + // update mapping + mapping.set(leftY, leftP) + } else { + break + } } } // find number of matching elements from right - for (;right < minCnt; right++) { - if (state.doc.content.child(pChildCnt - right - 1) !== mapping.getY(yChildren[yChildCnt - right - 1])) { - break + for (;right + left < minCnt; right++) { + const rightY = yChildren[yChildCnt - right - 1] + const rightP = pContent.child(pChildCnt - right - 1) + if (mapping.get(rightY) !== rightP) { + if (equalYTypePNode(rightY, rightP)) { + // update mapping + mapping.set(rightY, rightP) + } else { + break + } } } - if (left + right > pChildCnt) { - // nothing changed - return - } yDomFragment._y.transact(() => { - // now update y to match editor state - yDomFragment.delete(left, yChildCnt - left - right) - yDomFragment.insert(left, state.doc.content.content.slice(left, pChildCnt - right).map(node => createTypeFromNode(node, mapping))) + // try to compare and update + while (yChildCnt - left - right > 0 && pChildCnt - left - right > 0) { + const leftY = yChildren[left] + const leftP = pContent.child(left) + const rightY = yChildren[yChildCnt - right - 1] + 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)]) + } + left += 1 + } else { + let updateLeft = matchNodeName(leftY, leftP) + let updateRight = matchNodeName(rightY, rightP) + if (updateLeft && updateRight) { + // decide which which element to update + const equalityLeft = computeChildEqualityFactor(leftY, leftP, mapping) + const equalityRight = computeChildEqualityFactor(rightY, rightP, mapping) + if (equalityLeft.foundMappedChild && !equalityRight.foundMappedChild) { + updateRight = false + } else if (!equalityLeft.foundMappedChild && equalityRight.foundMappedChild) { + updateLeft = false + } else if (equalityLeft.equalityFactor < equalityRight.equalityFactor) { + updateLeft = false + } else { + updateRight = false + } + } + if (updateLeft) { + updateYFragment(leftY, leftP, mapping) + left += 1 + } else if (updateRight) { + updateYFragment(rightY, rightP, mapping) + right += 1 + } else { + yDomFragment.delete(left, 1) + yDomFragment.insert(left, [createTypeFromNode(leftP, mapping)]) + left += 1 + } + } + } + const yDelLen = yChildCnt - left - right + if (yDelLen > 0) { + yDomFragment.delete(left, yDelLen) + } + if (left + right < pChildCnt) { + const ins = [] + for (let i = left; i < pChildCnt - right; i++) { + ins.push(createTypeFromNode(pContent.child(i), mapping)) + } + yDomFragment.insert(left, ins) + } }) } + +/** + * @function + * @param {YXmlElement} yElement + * @param {any} pNode Prosemirror Node + */ +const matchNodeName = (yElement, pNode) => yElement.nodeName === pNode.type.name.toUpperCase() diff --git a/examples/prosemirror.html b/examples/prosemirror.html index 74279f0f..eb055e85 100644 --- a/examples/prosemirror.html +++ b/examples/prosemirror.html @@ -16,6 +16,13 @@ font-weight: bold; } .ProseMirror img { max-width: 100px } + /* this is a rough fix for the first cursor position when the first paragraph is empty */ + .ProseMirror > .ProseMirror-yjs-cursor:first-child { + margin-top: 16px; + } + .ProseMirror p:first-child, .ProseMirror h1:first-child, .ProseMirror h2:first-child, .ProseMirror h3:first-child, .ProseMirror h4:first-child, .ProseMirror h5:first-child, .ProseMirror h6:first-child { + margin-top: 16px + } .ProseMirror-yjs-cursor { position: absolute; border-left: black; diff --git a/lib/globals.js b/lib/globals.js index 43bdfe3e..2777d3c3 100644 --- a/lib/globals.js +++ b/lib/globals.js @@ -58,8 +58,6 @@ export const until = (timeout, check) => createPromise((resolve, reject) => { export const error = description => new Error(description) -export const max = (a, b) => a > b ? a : b - /** * @param {number} t Time to wait * @return {Promise} Promise that is resolved after t ms diff --git a/lib/math.js b/lib/math.js index 93af24e6..47efeb74 100644 --- a/lib/math.js +++ b/lib/math.js @@ -2,3 +2,27 @@ * @module math */ export const floor = Math.floor + +/** + * @function + * @param {number} a + * @param {number} b + * @return {number} The sum of a and b + */ +export const add = (a, b) => a + b + +/** + * @function + * @param {number} a + * @param {number} b + * @return {number} The smaller element of a and b + */ +export const min = (a, b) => a < b ? a : b + +/** + * @function + * @param {number} a + * @param {number} b + * @return {number} The bigger element of a and b + */ +export const max = (a, b) => a > b ? a : b diff --git a/lib/object.js b/lib/object.js new file mode 100644 index 00000000..f9a3f2ef --- /dev/null +++ b/lib/object.js @@ -0,0 +1,14 @@ + +export const create = Object.create(null) + +export const keys = Object.keys + +export const equalFlat = (a, b) => { + const keys = Object.keys(a) + let eq = keys.length === Object.keys(b).length + for (let i = 0; i < keys.length && eq; i++) { + const key = keys[i] + eq = a[key] === b[key] + } + return eq +} diff --git a/package.json b/package.json index 45b69a7b..007c1f1c 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,9 @@ "sideEffects": false, "scripts": { "test": "npm run lint", - "build": "rm -rf build examples/build && rollup -c", + "build": "rm -rf build examples/build && PRODUCTION=1 rollup -c", "watch": "rollup -wc", - "debug": "concurrently 'rollup -wc' 'cutest-serve build/y.test.js -o'", + "debug": "concurrently 'npm run watch' 'cutest-serve build/y.test.js -o'", "lint": "standard **/*.js", "docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.v13.md --package ./package.json || true", "serve-docs": "npm run docs && serve ./docs/", diff --git a/provider/websocket/WebSocketProvider.js b/provider/websocket/WebSocketProvider.js index 676fbbfa..ea2cd9e1 100644 --- a/provider/websocket/WebSocketProvider.js +++ b/provider/websocket/WebSocketProvider.js @@ -10,7 +10,7 @@ import * as bc from '../../lib/broadcastchannel.js' const messageSync = 0 const messageAwareness = 1 -const reconnectTimeout = 100 +const reconnectTimeout = 3000 /** * @param {WebsocketsSharedDocument} doc diff --git a/provider/websocket/server.js b/provider/websocket/server.js index aa088c07..6bbda267 100644 --- a/provider/websocket/server.js +++ b/provider/websocket/server.js @@ -32,7 +32,7 @@ const afterTransaction = (doc, transaction) => { class WSSharedDoc extends Y.Y { constructor () { - super() + super({ gc: true }) this.mux = Y.createMutex() /** * Maps from conn to set of controlled user ids. Delete all user ids from awareness when this conn is closed diff --git a/provider/ydb/idbactions.js b/provider/ydb/idbactions.js index a768f249..b88b025c 100644 --- a/provider/ydb/idbactions.js +++ b/provider/ydb/idbactions.js @@ -262,7 +262,7 @@ export const getRoomMetas = t => { result.push({ room: metakey.slice(5), rsid, - offset: keys.reduce((cur, key) => globals.max(decodeHUKey(key).offset, cur), offset) + offset: keys.reduce((cur, key) => math.max(decodeHUKey(key).offset, cur), offset) }) }) ).then(() => globals.presolve(result)) diff --git a/rollup.config.js b/rollup.config.js index a6d3cba8..bda843b2 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -3,6 +3,9 @@ import commonjs from 'rollup-plugin-commonjs' import babel from 'rollup-plugin-babel' import uglify from 'rollup-plugin-uglify-es' +// set this to [] to disable obfuscation +const minificationPlugins = process.env.PRODUCTION ? [babel(), uglify()] : [] + export default [{ input: './index.js', output: [{ @@ -39,10 +42,8 @@ export default [{ sourcemap: true, module: true }), - commonjs(), - babel(), - uglify() - ] + commonjs() + ].concat(minificationPlugins) }, { input: './examples/dom.js', output: { @@ -51,10 +52,7 @@ export default [{ format: 'iife', sourcemap: true }, - plugins: [ - babel(), - uglify() - ] + plugins: minificationPlugins }, { input: './examples/textarea.js', output: { @@ -63,10 +61,7 @@ export default [{ format: 'iife', sourcemap: true }, - plugins: [ - babel(), - uglify() - ] + plugins: minificationPlugins }, { input: './examples/quill.js', output: { @@ -80,8 +75,6 @@ export default [{ sourcemap: true, module: true }), - commonjs(), - babel(), - uglify() - ] + commonjs() + ].concat(minificationPlugins) }] diff --git a/structs/GC.js b/structs/GC.js index 55c94db7..969fe33c 100644 --- a/structs/GC.js +++ b/structs/GC.js @@ -19,6 +19,10 @@ export class GC { this._length = 0 } + get _redone () { + return null + } + get _deleted () { return true } diff --git a/structs/Item.js b/structs/Item.js index 0f1cd9cf..e1887d5f 100644 --- a/structs/Item.js +++ b/structs/Item.js @@ -113,6 +113,30 @@ export class Item { this._redone = null } + /** + * Returns the next non-deleted item + * @private + */ + get _next () { + let n = this._right + while (n !== null && n._deleted) { + n = n._right + } + return n + } + + /** + * Returns the previous non-deleted item + * @private + */ + get _prev () { + let n = this._left + while (n !== null && n._deleted) { + n = n._left + } + return n + } + /** * Creates an Item with the same effect as this Item (without position effect) * @@ -127,7 +151,7 @@ export class Item { * Redoes the effect of this operation. * * @param {Y} y The Yjs instance. - * @param {Array} redoitems + * @param {Set} redoitems * * @private */ diff --git a/structs/Type.js b/structs/Type.js index 7e8fe15d..cee62ef2 100644 --- a/structs/Type.js +++ b/structs/Type.js @@ -57,6 +57,17 @@ export class Type extends Item { this._deepEventHandler = new EventHandler() } + /** + * The first non-deleted item + */ + get _first () { + let n = this._start + while (n !== null && n._deleted) { + n = n._right + } + return n + } + /** * Compute the path from this type to the specified target. * diff --git a/types/YArray.js b/types/YArray.js index 925bf72f..3fb196b0 100644 --- a/types/YArray.js +++ b/types/YArray.js @@ -95,10 +95,12 @@ export class YArray extends Type { while (n !== null) { if (!n._deleted && n._countable) { if (index < n._length) { - if (n.constructor === ItemJSON || n.constructor === ItemString) { - return n._content[index] - } else { - return n + switch (n.constructor) { + case ItemJSON: + case ItemString: + return n._content[index] + default: + return n } } index -= n._length diff --git a/types/YText.js b/types/YText.js index a3cc6aec..ab321707 100644 --- a/types/YText.js +++ b/types/YText.js @@ -485,6 +485,10 @@ export class YText extends YArray { this._callEventHandler(transaction, new YTextEvent(this, remote, transaction)) } + toDom () { + return document.createTextNode(this.toString()) + } + /** * Returns the unformatted string representation of this YText type. * diff --git a/utils/EventHandler.js b/utils/EventHandler.js index 245c00b3..11a11759 100644 --- a/utils/EventHandler.js +++ b/utils/EventHandler.js @@ -49,14 +49,14 @@ export class EventHandler { * Call all event listeners that were added via * {@link EventHandler#addEventListener}. * - * @param {Transaction} transaction The transaction object // TODO: do we need this? + * @param {Transaction} transaction The transaction object * @param {YEvent} event An event object that describes the change on a type. */ callEventListeners (transaction, event) { for (var i = 0; i < this.eventListeners.length; i++) { try { const f = this.eventListeners[i] - f(event) + f(event, transaction) } catch (e) { /* Your observer threw an error. This error was caught so that Yjs diff --git a/utils/Y.js b/utils/Y.js index 17ff70ba..2b7d9b91 100644 --- a/utils/Y.js +++ b/utils/Y.js @@ -28,17 +28,11 @@ import { Decoder } from '../lib/decoding.js' // eslint-disable-line */ export class Y extends NamedEventHandler { /** - * @param {string} room Users in the same room share the same content - * @param {Object} conf configuration + * @param {Object} [conf] configuration */ - constructor (room, conf = {}) { + constructor (conf = {}) { super() this.gcEnabled = conf.gc || false - /** - * The room name that this Yjs instance connects to. - * @type {String} - */ - this.room = room this._contentReady = false this.userID = generateRandomUint32() // TODO: This should be a Map so we can use encodables as keys diff --git a/utils/relativePosition.js b/utils/relativePosition.js index 8f15b2e1..9cf540be 100644 --- a/utils/relativePosition.js +++ b/utils/relativePosition.js @@ -46,7 +46,7 @@ export const getRelativePosition = (type, offset) => { // TODO: rename to createRelativePosition let t = type._start while (t !== null) { - if (t._deleted === false) { + if (!t._deleted && t._countable) { if (t._length > offset) { return [t._id.user, t._id.clock + offset] } @@ -60,7 +60,7 @@ export const getRelativePosition = (type, offset) => { /** * @typedef {Object} AbsolutePosition The result of {@link fromRelativePosition} * @property {YType} type The type on which to apply the absolute position. - * @property {Integer} offset The absolute offset.r + * @property {number} offset The absolute offset.r */ /** @@ -72,6 +72,9 @@ export const getRelativePosition = (type, offset) => { * (type + offset). */ export const fromRelativePosition = (y, rpos) => { + if (rpos === null) { + return null + } if (rpos[0] === 'endof') { let id if (rpos[3] === null) { @@ -80,6 +83,9 @@ export const fromRelativePosition = (y, rpos) => { id = ID.createRootID(rpos[3], rpos[4]) } let type = y.os.get(id) + if (type === null) { + return null + } while (type._redone !== null) { type = type._redone } @@ -93,6 +99,9 @@ export const fromRelativePosition = (y, rpos) => { } else { let offset = 0 let struct = y.os.findNodeWithUpperBound(ID.createID(rpos[0], rpos[1])).val + if (struct === null || struct._id.user === ID.RootFakeUserID) { + return null // TODO: support fake ids? + } const diff = rpos[1] - struct._id.clock while (struct._redone !== null) { struct = struct._redone @@ -101,12 +110,12 @@ export const fromRelativePosition = (y, rpos) => { if (struct.constructor === GC || parent._deleted) { return null } - if (!struct._deleted) { + if (!struct._deleted && struct._countable) { offset = diff } struct = struct._left while (struct !== null) { - if (!struct._deleted) { + if (!struct._deleted && struct._countable) { offset += struct._length } struct = struct._left @@ -117,3 +126,5 @@ export const fromRelativePosition = (y, rpos) => { } } } + +export const equal = (posa, posb) => posa === posb || (posa !== null && posb !== null && posa.length === posb.length && posa.every((v, i) => v === posb[i]))