improved granularity of prosemirror binding

This commit is contained in:
Kevin Jahns 2018-12-03 17:09:00 +01:00
parent c9ea3a412e
commit 582095e5a3
18 changed files with 519 additions and 126 deletions

View File

@ -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<YText | YXmlElement, PModel.Node>} ProsemirrorMapping
* @typedef {Map<YText | YXmlElement | YXmlFragment, PModel.Node>} 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<PModel.Node>}
@ -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()

View File

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

View File

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

View File

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

14
lib/object.js Normal file
View File

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

View File

@ -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/",

View File

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

View File

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

View File

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

View File

@ -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)
}]

View File

@ -19,6 +19,10 @@ export class GC {
this._length = 0
}
get _redone () {
return null
}
get _deleted () {
return true
}

View File

@ -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<Item>} redoitems
* @param {Set<Item>} redoitems
*
* @private
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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