/** * @module bindings/prosemirror */ 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, 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 {Map} ProsemirrorMapping */ /** * The unique prosemirror plugin key for prosemirrorPlugin. * * @public */ export const prosemirrorPluginKey = new PluginKey('yjs') /** * This plugin listens to changes in prosemirror view and keeps yXmlState and view in sync. * * This plugin also keeps references to the type and the shared document so other plugins can access it. * @param {YXmlFragment} yXmlFragment * @return {Plugin} Returns a prosemirror plugin that binds to this type */ export const prosemirrorPlugin = yXmlFragment => { const pluginState = { type: yXmlFragment, y: yXmlFragment._y, binding: null } let changedInitialContent = false const plugin = new Plugin({ key: prosemirrorPluginKey, state: { init: (initargs, state) => { 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 } }, view: view => { const binding = new ProsemirrorBinding(yXmlFragment, view) pluginState.binding = binding return { update: () => { if (changedInitialContent || view.state.doc.content.size > 4) { changedInitialContent = true binding._prosemirrorChanged(view.state.doc) } }, destroy: () => { binding.destroy() } } } }) return plugin } /** * The unique prosemirror plugin key for cursorPlugin.type * * @public */ export const cursorPluginKey = new PluginKey('yjs-cursor') /** * A prosemirror plugin that listens to awareness information on Yjs. * This requires that a `prosemirrorPlugin` is also bound to the prosemirror. * * @public */ export const cursorPlugin = new Plugin({ key: cursorPluginKey, props: { decorations: state => { const ystate = prosemirrorPluginKey.getState(state) const y = ystate.y const awareness = y.getAwarenessInfo() const decorations = [] awareness.forEach((aw, userID) => { if (aw.cursor != null) { let user = aw.user || {} if (user.color == null) { user.color = '#ffa500' } if (user.name == null) { user.name = `User: ${userID}` } 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') cursor.setAttribute('style', `border-color: ${user.color}`) const userDiv = document.createElement('div') userDiv.setAttribute('style', `background-color: ${user.color}`) userDiv.insertBefore(document.createTextNode(user.name), null) cursor.insertBefore(userDiv, null) return cursor }, { key: userID + '' })) const from = math.min(anchor, head) const to = math.max(anchor, head) decorations.push(Decoration.inline(from, to, { style: `background-color: ${user.color}70` })) } } }) return DecorationSet.create(state.doc, decorations) } }, view: view => { const ystate = prosemirrorPluginKey.getState(view.state) const y = ystate.y const awarenessListener = () => { view.updateState(view.state) } 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', { 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) y.off('awareness', awarenessListener) } } } }) /** * 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. * * @protected */ export class ProsemirrorBinding { /** * @param {YXmlFragment} yXmlFragment The bind source * @param {EditorView} prosemirrorView The target binding */ constructor (yXmlFragment, prosemirrorView) { this.type = yXmlFragment this.prosemirrorView = prosemirrorView this.mux = createMutex() /** * @type {ProsemirrorMapping} */ 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, transaction) { if (events.length === 0) { return } console.info('new types:', transaction.newTypes.size, 'deleted types:', transaction.deletedStructs.size, transaction.newTypes, transaction.deletedStructs) this.mux(() => { 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 (doc) { this.mux(() => { updateYFragment(this.type, doc.content, this.mapping) }) } destroy () { this.type.unobserveDeep(this._observeFunction) } } /** * @privateMapping * @param {YXmlElement} el * @param {PModel.Schema} schema * @param {ProsemirrorMapping} mapping * @return {PModel.Node} */ export const createNodeIfNotExists = (el, schema, mapping) => { const node = mapping.get(el) if (node === undefined) { return createNodeFromYElement(el, schema, mapping) } return node } /** * @private * @param {YXmlElement} el * @param {PModel.Schema} schema * @param {ProsemirrorMapping} mapping * @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) { const n = createNodeIfNotExists(type, schema, mapping) if (n !== null) { children.push(n) } } else { const ns = createTextNodesFromYText(type, schema, mapping) if (ns !== null) { ns.forEach(textchild => { if (textchild !== null) { children.push(textchild) } }) } } }) 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 {YText} text * @param {PModel.Schema} schema * @param {ProsemirrorMapping} mapping * @return {Array} */ export const createTextNodesFromYText = (text, schema, mapping) => { const nodes = [] const deltas = text.toDelta() 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)) } 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 } /** * @private * @param {PModel.Node} node * @param {ProsemirrorMapping} mapping * @return {YXmlElement | YText} */ export const createTypeFromNode = (node, mapping) => { let type if (node.isText) { type = new YText() const attrs = {} node.marks.forEach(mark => { 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) { type.setAttribute(key, val) } } const ins = [] for (let i = 0; i < node.childCount; i++) { ins.push(createTypeFromNode(node.child(i), mapping)) } type.insert(0, ins) } mapping.set(type, node) return type } const equalAttrs = (pattrs, yattrs) => { const keys = Object.keys(pattrs).filter(key => pattrs[key] === null) 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] } return eq } 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 => equalAttrs(d.attributes[mark.type.name], mark.attrs)) } const equalYTypePNode = (ytype, pnode) => ytype.constructor === YText ? equalYTextPText(ytype, pnode) : (matchNodeName(ytype, pnode) && ytype.length === pnode.childCount && equalAttrs(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 {PModel.Node} pContent * @param {ProsemirrorMapping} mapping */ 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() const pAttrs = pContent.attrs for (let key in pAttrs) { if (pAttrs[key] !== null) { if (yDomAttrs[key] !== pAttrs[key]) { yDomFragment.setAttribute(key, pAttrs[key]) } } else { yDomFragment.removeAttribute(key) } } // remove all keys that are no longer in pAttrs for (let key in yDomAttrs) { if (pAttrs[key] === undefined) { yDomFragment.removeAttribute(key) } } } // update children const pChildCnt = pContent.childCount const yChildren = yDomFragment.toArray() const yChildCnt = yChildren.length const minCnt = math.min(pChildCnt, yChildCnt) let left = 0 let right = 0 // find number of matching elements from left for (;left < minCnt; left++) { 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 + 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 } } } yDomFragment._y.transact(() => { // 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()