Merge pull request #44 from fantasticit/fix/dep

This commit is contained in:
fantasticit 2022-05-20 22:25:05 +08:00 committed by GitHub
commit b9e6564511
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 2258 additions and 869 deletions

1
.gitignore vendored
View File

@ -7,7 +7,6 @@ dist-ssr
coverage coverage
test-results test-results
.pnpm-store .pnpm-store
.npmrc
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
.env .env

1
.npmrc Normal file
View File

@ -0,0 +1 @@
strict-peer-dependencies=false

View File

@ -39,6 +39,7 @@
"node": ">=16.5.0" "node": ">=16.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^17.0.35",
"husky": "^7.0.4", "husky": "^7.0.4",
"lint-staged": "^12.4.1", "lint-staged": "^12.4.1",
"prettier": "^2.3.2", "prettier": "^2.3.2",

View File

@ -1,6 +1,6 @@
const semi = require('@douyinfe/semi-next').default({}); const semi = require('@douyinfe/semi-next').default({});
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const withOffline = require('next-offline'); const withPWA = require('next-pwa');
const { getConfig } = require('@think/config'); const { getConfig } = require('@think/config');
const config = getConfig(); const config = getConfig();
@ -27,26 +27,11 @@ const nextConfig = semi({
compiler: { compiler: {
removeConsole: true, removeConsole: true,
}, },
workboxOpts: { pwa: {
runtimeCaching: [ disable: process.env.NODE_ENV !== 'production',
{ dest: '.next',
urlPattern: /.(png|jpg|jpeg|svg|webp)$/, sw: 'service-worker.js',
handler: 'CacheFirst',
},
{
urlPattern: /api/,
handler: 'NetworkFirst',
options: {
cacheableResponse: {
statuses: [0, 200],
headers: {
'x-sw': 'true',
},
},
},
},
],
}, },
}); });
module.exports = withOffline(nextConfig); module.exports = withPWA(nextConfig);

View File

@ -71,7 +71,7 @@
"markdown-it-sub": "^1.0.0", "markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0", "markdown-it-sup": "^1.0.0",
"next": "12.0.10", "next": "12.0.10",
"next-offline": "^5.0.5", "next-pwa": "^5.5.2",
"prosemirror-markdown": "^1.7.0", "prosemirror-markdown": "^1.7.0",
"prosemirror-model": "^1.16.1", "prosemirror-model": "^1.16.1",
"prosemirror-schema-list": "^1.1.6", "prosemirror-schema-list": "^1.1.6",
@ -93,8 +93,6 @@
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"toggle-selection": "^1.0.6", "toggle-selection": "^1.0.6",
"viewerjs": "^1.10.4", "viewerjs": "^1.10.4",
"y-indexeddb": "^9.0.7",
"y-prosemirror": "^1.0.14",
"yjs": "^13.5.24" "yjs": "^13.5.24"
}, },
"devDependencies": { "devDependencies": {
@ -102,6 +100,7 @@
"@types/react": "17.0.38", "@types/react": "17.0.38",
"@typescript-eslint/eslint-plugin": "^5.21.0", "@typescript-eslint/eslint-plugin": "^5.21.0",
"@typescript-eslint/parser": "^5.21.0", "@typescript-eslint/parser": "^5.21.0",
"copy-webpack-plugin": "11.0.0",
"eslint": "^8.14.0", "eslint": "^8.14.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-import": "^2.26.0",
@ -109,7 +108,9 @@
"eslint-plugin-react": "^7.29.4", "eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.5.0", "eslint-plugin-react-hooks": "^4.5.0",
"eslint-plugin-simple-import-sort": "^7.0.0", "eslint-plugin-simple-import-sort": "^7.0.0",
"fs-extra": "^10.0.0",
"tsconfig-paths-webpack-plugin": "^3.5.2", "tsconfig-paths-webpack-plugin": "^3.5.2",
"typescript": "4.5.5" "typescript": "4.5.5",
"workbox-webpack-plugin": "^6.5.3"
} }
} }

View File

@ -1,8 +1,13 @@
import * as math from 'lib0/math'; import * as math from 'lib0/math';
import { Plugin } from 'prosemirror-state'; // eslint-disable-line import { Plugin } from 'prosemirror-state'; // eslint-disable-line
import { Decoration, DecorationSet } from 'prosemirror-view'; // eslint-disable-line import { Decoration, DecorationSet } from 'prosemirror-view'; // eslint-disable-line
import { absolutePositionToRelativePosition, relativePositionToAbsolutePosition, setMeta } from 'y-prosemirror'; import {
import { yCursorPluginKey, ySyncPluginKey } from 'y-prosemirror'; absolutePositionToRelativePosition,
relativePositionToAbsolutePosition,
setMeta,
yCursorPluginKey,
ySyncPluginKey,
} from 'tiptap/core/y-prosemirror/y-prosemirror';
import * as Y from 'yjs'; import * as Y from 'yjs';
/** /**

View File

@ -1,5 +1,5 @@
import { Extension } from '@tiptap/core'; import { Extension } from '@tiptap/core';
import { redo, undo, ySyncPlugin, yUndoPlugin, yUndoPluginKey } from 'y-prosemirror'; import { redo, undo, ySyncPlugin, yUndoPlugin, yUndoPluginKey } from 'tiptap/core/y-prosemirror/y-prosemirror';
import { UndoManager } from 'yjs'; import { UndoManager } from 'yjs';
declare module '@tiptap/core' { declare module '@tiptap/core' {

View File

@ -1,5 +1,5 @@
import { Transaction } from 'prosemirror-state'; import { Transaction } from 'prosemirror-state';
import { ySyncPluginKey } from 'y-prosemirror'; import { ySyncPluginKey } from 'tiptap/core/y-prosemirror/y-prosemirror';
export function isChangeOrigin(transaction: Transaction): boolean { export function isChangeOrigin(transaction: Transaction): boolean {
return !!transaction.getMeta(ySyncPluginKey); return !!transaction.getMeta(ySyncPluginKey);

View File

@ -0,0 +1,189 @@
import * as idb from 'lib0/indexeddb.js';
import * as mutex from 'lib0/mutex.js';
import { Observable } from 'lib0/observable.js';
import * as Y from 'yjs';
const customStoreName = 'custom';
const updatesStoreName = 'updates';
export const PREFERRED_TRIM_SIZE = 500;
/**
* @param {IndexeddbPersistence} idbPersistence
*/
export const fetchUpdates = (idbPersistence) => {
const [updatesStore] = idb.transact(/** @type {IDBDatabase} */ (idbPersistence.db), [updatesStoreName]); // , 'readonly')
return idb
.getAll(updatesStore, idb.createIDBKeyRangeLowerBound(idbPersistence._dbref, false))
.then((updates) =>
idbPersistence._mux(() =>
Y.transact(
idbPersistence.doc,
() => {
updates.forEach((val) => Y.applyUpdate(idbPersistence.doc, val));
},
idbPersistence,
false
)
)
)
.then(() =>
idb.getLastKey(updatesStore).then((lastKey) => {
idbPersistence._dbref = lastKey + 1;
})
)
.then(() =>
idb.count(updatesStore).then((cnt) => {
idbPersistence._dbsize = cnt;
})
)
.then(() => updatesStore);
};
/**
* @param {IndexeddbPersistence} idbPersistence
* @param {boolean} forceStore
*/
export const storeState = (idbPersistence, forceStore = true) =>
fetchUpdates(idbPersistence).then((updatesStore) => {
if (forceStore || idbPersistence._dbsize >= PREFERRED_TRIM_SIZE) {
idb
.addAutoKey(updatesStore, Y.encodeStateAsUpdate(idbPersistence.doc))
.then(() => idb.del(updatesStore, idb.createIDBKeyRangeUpperBound(idbPersistence._dbref, true)))
.then(() =>
idb.count(updatesStore).then((cnt) => {
idbPersistence._dbsize = cnt;
})
);
}
});
/**
* @param {string} name
*/
export const clearDocument = (name) => idb.deleteDB(name);
/**
* @extends Observable<string>
*/
export class IndexeddbPersistence extends Observable {
/**
* @param {string} name
* @param {Y.Doc} doc
*/
constructor(name, doc) {
super();
this.doc = doc;
this.name = name;
this._mux = mutex.createMutex();
this._dbref = 0;
this._dbsize = 0;
/**
* @type {IDBDatabase|null}
*/
this.db = null;
this.synced = false;
this._db = idb.openDB(name, (db) => idb.createStores(db, [['updates', { autoIncrement: true }], ['custom']]));
/**
* @type {Promise<IndexeddbPersistence>}
*/
this.whenSynced = this._db.then((db) => {
this.db = db;
const currState = Y.encodeStateAsUpdate(doc);
return fetchUpdates(this)
.then((updatesStore) => idb.addAutoKey(updatesStore, currState))
.then(() => {
this.emit('synced', [this]);
this.synced = true;
return this;
});
});
/**
* Timeout in ms untill data is merged and persisted in idb.
*/
this._storeTimeout = 1000;
/**
* @type {any}
*/
this._storeTimeoutId = null;
/**
* @param {Uint8Array} update
*/
this._storeUpdate = (update) =>
this._mux(() => {
if (this.db) {
const [updatesStore] = idb.transact(/** @type {IDBDatabase} */ (this.db), [updatesStoreName]);
idb.addAutoKey(updatesStore, update);
if (++this._dbsize >= PREFERRED_TRIM_SIZE) {
// debounce store call
if (this._storeTimeoutId !== null) {
clearTimeout(this._storeTimeoutId);
}
this._storeTimeoutId = setTimeout(() => {
storeState(this, false);
this._storeTimeoutId = null;
}, this._storeTimeout);
}
}
});
doc.on('update', this._storeUpdate);
this.destroy = this.destroy.bind(this);
doc.on('destroy', this.destroy);
}
destroy() {
if (this._storeTimeoutId) {
clearTimeout(this._storeTimeoutId);
}
this.doc.off('update', this._storeUpdate);
this.doc.off('destroy', this.destroy);
return this._db.then((db) => {
db.close();
});
}
/**
* Destroys this instance and removes all data from indexeddb.
*
* @return {Promise<void>}
*/
clearData() {
return this.destroy().then(() => {
idb.deleteDB(this.name);
});
}
/**
* @param {String | number | ArrayBuffer | Date} key
* @return {Promise<String | number | ArrayBuffer | Date | any>}
*/
get(key) {
return this._db.then((db) => {
const [custom] = idb.transact(db, [customStoreName], 'readonly');
return idb.get(custom, key);
});
}
/**
* @param {String | number | ArrayBuffer | Date} key
* @param {String | number | ArrayBuffer | Date} value
* @return {Promise<String | number | ArrayBuffer | Date>}
*/
set(key, value) {
return this._db.then((db) => {
const [custom] = idb.transact(db, [customStoreName]);
return idb.put(custom, value, key);
});
}
/**
* @param {String | number | ArrayBuffer | Date} key
* @return {Promise<undefined>}
*/
del(key) {
return this._db.then((db) => {
const [custom] = idb.transact(db, [customStoreName]);
return idb.del(custom, key);
});
}
}

View File

@ -0,0 +1,318 @@
import { updateYFragment } from './plugins/sync-plugin.js'; // eslint-disable-line
import * as Y from 'yjs';
import { EditorView } from 'prosemirror-view'; // eslint-disable-line
import { Node, Schema } from 'prosemirror-model'; // eslint-disable-line
import * as error from 'lib0/error';
import * as map from 'lib0/map';
import * as eventloop from 'lib0/eventloop';
/**
* Either a node if type is YXmlElement or an Array of text nodes if YXmlText
* @typedef {Map<Y.AbstractType, Node | Array<Node>>} ProsemirrorMapping
*/
/**
* Is null if no timeout is in progress.
* Is defined if a timeout is in progress.
* Maps from view
* @type {Map<EditorView, Map<any, any>>|null}
*/
let viewsToUpdate = null;
const updateMetas = () => {
const ups = /** @type {Map<EditorView, Map<any, any>>} */ (viewsToUpdate);
viewsToUpdate = null;
ups.forEach((metas, view) => {
const tr = view.state.tr;
metas.forEach((val, key) => {
tr.setMeta(key, val);
});
view.dispatch(tr);
});
};
export const setMeta = (view, key, value) => {
if (!viewsToUpdate) {
viewsToUpdate = new Map();
eventloop.timeout(0, updateMetas);
}
map.setIfUndefined(viewsToUpdate, view, map.create).set(key, value);
};
/**
* Transforms a Prosemirror based absolute position to a Yjs Cursor (relative position in the Yjs model).
*
* @param {number} pos
* @param {Y.XmlFragment} type
* @param {ProsemirrorMapping} mapping
* @return {any} relative position
*/
export const absolutePositionToRelativePosition = (pos, type, mapping) => {
if (pos === 0) {
return Y.createRelativePositionFromTypeIndex(type, 0);
}
/**
* @type {any}
*/
let n = type._first === null ? null : /** @type {Y.ContentType} */ (type._first.content).type;
while (n !== null && type !== n) {
if (n instanceof Y.XmlText) {
if (n._length >= pos) {
return Y.createRelativePositionFromTypeIndex(n, pos);
} else {
pos -= n._length;
}
if (n._item !== null && n._item.next !== null) {
n = /** @type {Y.ContentType} */ (n._item.next.content).type;
} else {
do {
n = n._item === null ? null : n._item.parent;
pos--;
} while (n !== type && n !== null && n._item !== null && n._item.next === null);
if (n !== null && n !== type) {
// @ts-gnore we know that n.next !== null because of above loop conditition
n = n._item === null ? null : /** @type {Y.ContentType} */ (/** @type Y.Item */ (n._item.next).content).type;
}
}
} else {
const pNodeSize = /** @type {any} */ (mapping.get(n) || { nodeSize: 0 }).nodeSize;
if (n._first !== null && pos < pNodeSize) {
n = /** @type {Y.ContentType} */ (n._first.content).type;
pos--;
} else {
if (pos === 1 && n._length === 0 && pNodeSize > 1) {
// edge case, should end in this paragraph
return new Y.RelativePosition(
n._item === null ? null : n._item.id,
n._item === null ? Y.findRootTypeKey(n) : null,
null
);
}
pos -= pNodeSize;
if (n._item !== null && n._item.next !== null) {
n = /** @type {Y.ContentType} */ (n._item.next.content).type;
} else {
if (pos === 0) {
// set to end of n.parent
n = n._item === null ? n : n._item.parent;
return new Y.RelativePosition(
n._item === null ? null : n._item.id,
n._item === null ? Y.findRootTypeKey(n) : null,
null
);
}
do {
n = /** @type {Y.Item} */ (n._item).parent;
pos--;
} while (n !== type && /** @type {Y.Item} */ (n._item).next === null);
// if n is null at this point, we have an unexpected case
if (n !== type) {
// We know that n._item.next is defined because of above loop condition
n = /** @type {Y.ContentType} */ (/** @type {Y.Item} */ (/** @type {Y.Item} */ (n._item).next).content)
.type;
}
}
}
}
if (n === null) {
throw error.unexpectedCase();
}
if (pos === 0 && n.constructor !== Y.XmlText && n !== type) {
// TODO: set to <= 0
return createRelativePosition(n._item.parent, n._item);
}
}
return Y.createRelativePositionFromTypeIndex(type, type._length);
};
const createRelativePosition = (type, item) => {
let typeid = null;
let tname = null;
if (type._item === null) {
tname = Y.findRootTypeKey(type);
} else {
typeid = Y.createID(type._item.id.client, type._item.id.clock);
}
return new Y.RelativePosition(typeid, tname, item.id);
};
/**
* @param {Y.Doc} y
* @param {Y.XmlFragment} documentType Top level type that is bound to pView
* @param {any} relPos Encoded Yjs based relative position
* @param {ProsemirrorMapping} mapping
* @return {null|number}
*/
export const relativePositionToAbsolutePosition = (y, documentType, relPos, mapping) => {
const decodedPos = Y.createAbsolutePositionFromRelativePosition(relPos, y);
if (decodedPos === null || (decodedPos.type !== documentType && !Y.isParentOf(documentType, decodedPos.type._item))) {
return null;
}
let type = decodedPos.type;
let pos = 0;
if (type.constructor === Y.XmlText) {
pos = decodedPos.index;
} else if (type._item === null || !type._item.deleted) {
let n = type._first;
let i = 0;
while (i < type._length && i < decodedPos.index && n !== null) {
if (!n.deleted) {
const t = /** @type {Y.ContentType} */ (n.content).type;
i++;
if (t instanceof Y.XmlText) {
pos += t._length;
} else {
pos += /** @type {any} */ (mapping.get(t)).nodeSize;
}
}
n = /** @type {Y.Item} */ (n.right);
}
pos += 1; // increase because we go out of n
}
while (type !== documentType && type._item !== null) {
// @ts-ignore
const parent = type._item.parent;
// @ts-ignore
if (parent._item === null || !parent._item.deleted) {
pos += 1; // the start tag
let n = /** @type {Y.AbstractType} */ (parent)._first;
// now iterate until we found type
while (n !== null) {
const contentType = /** @type {Y.ContentType} */ (n.content).type;
if (contentType === type) {
break;
}
if (!n.deleted) {
if (contentType instanceof Y.XmlText) {
pos += contentType._length;
} else {
pos += /** @type {any} */ (mapping.get(contentType)).nodeSize;
}
}
n = n.right;
}
}
type = /** @type {Y.AbstractType} */ (parent);
}
return pos - 1; // we don't count the most outer tag, because it is a fragment
};
/**
* Utility method to convert a Prosemirror Doc Node into a Y.Doc.
*
* This can be used when importing existing content to Y.Doc for the first time,
* note that this should not be used to rehydrate a Y.Doc from a database once
* collaboration has begun as all history will be lost
*
* @param {Node} doc
* @param {string} xmlFragment
* @return {Y.Doc}
*/
export function prosemirrorToYDoc(doc, xmlFragment = 'prosemirror') {
const ydoc = new Y.Doc();
const type = /** @type {Y.XmlFragment} */ (ydoc.get(xmlFragment, Y.XmlFragment));
if (!type.doc) {
return ydoc;
}
updateYFragment(type.doc, type, doc, new Map());
return type.doc;
}
/**
* Utility method to convert Prosemirror compatible JSON into a Y.Doc.
*
* This can be used when importing existing content to Y.Doc for the first time,
* note that this should not be used to rehydrate a Y.Doc from a database once
* collaboration has begun as all history will be lost
*
* @param {Schema} schema
* @param {any} state
* @param {string} xmlFragment
* @return {Y.Doc}
*/
export function prosemirrorJSONToYDoc(schema, state, xmlFragment = 'prosemirror') {
const doc = Node.fromJSON(schema, state);
return prosemirrorToYDoc(doc, xmlFragment);
}
/**
* Utility method to convert a Y.Doc to a Prosemirror Doc node.
*
* @param {Schema} schema
* @param {Y.Doc} ydoc
* @return {Node}
*/
export function yDocToProsemirror(schema, ydoc) {
const state = yDocToProsemirrorJSON(ydoc);
return Node.fromJSON(schema, state);
}
/**
* Utility method to convert a Y.Doc to Prosemirror compatible JSON.
*
* @param {Y.Doc} ydoc
* @param {string} xmlFragment
* @return {Record<string, any>}
*/
export function yDocToProsemirrorJSON(ydoc, xmlFragment = 'prosemirror') {
const items = ydoc.getXmlFragment(xmlFragment).toArray();
function serialize(item) {
/**
* @type {Object} NodeObject
* @property {string} NodeObject.type
* @property {Record<string, string>=} NodeObject.attrs
* @property {Array<NodeObject>=} NodeObject.content
*/
let response;
// TODO: Must be a better way to detect text nodes than this
if (!item.nodeName) {
const delta = item.toDelta();
response = delta.map((d) => {
const text = {
type: 'text',
text: d.insert,
};
if (d.attributes) {
text.marks = Object.keys(d.attributes).map((type) => {
const attrs = d.attributes[type];
const mark = {
type,
};
if (Object.keys(attrs)) {
mark.attrs = attrs;
}
return mark;
});
}
return text;
});
} else {
response = {
type: item.nodeName,
};
const attrs = item.getAttributes();
if (Object.keys(attrs).length) {
response.attrs = attrs;
}
const children = item.toArray();
if (children.length) {
response.content = children.map(serialize).flat();
}
}
return response;
}
return {
type: 'doc',
content: items.map(serialize),
};
}

View File

@ -0,0 +1,184 @@
import * as math from 'lib0/math';
import { Plugin } from 'prosemirror-state'; // eslint-disable-line
import { Decoration, DecorationSet } from 'prosemirror-view'; // eslint-disable-line
import { Awareness } from 'y-protocols/awareness'; // eslint-disable-line
import * as Y from 'yjs';
import { absolutePositionToRelativePosition, relativePositionToAbsolutePosition, setMeta } from '../lib.js';
import { yCursorPluginKey, ySyncPluginKey } from './keys.js';
/**
* Default generator for a cursor element
*
* @param {any} user user data
* @return HTMLElement
*/
export const defaultCursorBuilder = (user) => {
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;
};
const rxValidColor = /^#[0-9a-fA-F]{6}$/;
/**
* @param {any} state
* @param {Awareness} awareness
* @return {any} DecorationSet
*/
export const createDecorations = (state, awareness, createCursor) => {
const ystate = ySyncPluginKey.getState(state);
const y = ystate.doc;
const decorations = [];
if (ystate.snapshot != null || ystate.prevSnapshot != null || ystate.binding === null) {
// do not render cursors while snapshot is active
return DecorationSet.create(state.doc, []);
}
awareness.getStates().forEach((aw, clientId) => {
if (clientId === y.clientID) {
return;
}
if (aw.cursor != null) {
const user = aw.user || {};
if (user.color == null) {
user.color = '#ffa500';
} else if (!rxValidColor.test(user.color)) {
// We only support 6-digit RGB colors in y-prosemirror
console.warn('A user uses an unsupported color format', user);
}
if (user.name == null) {
user.name = `User: ${clientId}`;
}
let anchor = relativePositionToAbsolutePosition(
y,
ystate.type,
Y.createRelativePositionFromJSON(aw.cursor.anchor),
ystate.binding.mapping
);
let head = relativePositionToAbsolutePosition(
y,
ystate.type,
Y.createRelativePositionFromJSON(aw.cursor.head),
ystate.binding.mapping
);
if (anchor !== null && head !== null) {
const maxsize = math.max(state.doc.content.size - 1, 0);
anchor = math.min(anchor, maxsize);
head = math.min(head, maxsize);
decorations.push(Decoration.widget(head, () => createCursor(user), { key: clientId + '', side: 10 }));
const from = math.min(anchor, head);
const to = math.max(anchor, head);
decorations.push(
Decoration.inline(
from,
to,
{ style: `background-color: ${user.color}70` },
{ inclusiveEnd: true, inclusiveStart: false }
)
);
}
}
});
return DecorationSet.create(state.doc, decorations);
};
/**
* A prosemirror plugin that listens to awareness information on Yjs.
* This requires that a `prosemirrorPlugin` is also bound to the prosemirror.
*
* @public
* @param {Awareness} awareness
* @param {object} [opts]
* @param {function(any):HTMLElement} [opts.cursorBuilder]
* @param {function(any):any} [opts.getSelection]
* @param {string} [opts.cursorStateField] By default all editor bindings use the awareness 'cursor' field to propagate cursor information.
* @return {any}
*/
export const yCursorPlugin = (
awareness,
{ cursorBuilder = defaultCursorBuilder, getSelection = (state) => state.selection } = {},
cursorStateField = 'cursor'
) =>
new Plugin({
key: yCursorPluginKey,
state: {
init(_, state) {
return createDecorations(state, awareness, cursorBuilder);
},
apply(tr, prevState, oldState, newState) {
const ystate = ySyncPluginKey.getState(newState);
const yCursorState = tr.getMeta(yCursorPluginKey);
if ((ystate && ystate.isChangeOrigin) || (yCursorState && yCursorState.awarenessUpdated)) {
return createDecorations(newState, awareness, cursorBuilder);
}
return prevState.map(tr.mapping, tr.doc);
},
},
props: {
decorations: (state) => {
return yCursorPluginKey.getState(state);
},
},
view: (view) => {
const awarenessListener = () => {
// @ts-ignore
if (view.docView) {
setMeta(view, yCursorPluginKey, { awarenessUpdated: true });
}
};
const updateCursorInfo = () => {
const ystate = ySyncPluginKey.getState(view.state);
// @note We make implicit checks when checking for the cursor property
const current = awareness.getLocalState() || {};
if (view.hasFocus() && ystate.binding !== null) {
const selection = getSelection(view.state);
/**
* @type {Y.RelativePosition}
*/
const anchor = absolutePositionToRelativePosition(selection.anchor, ystate.type, ystate.binding.mapping);
/**
* @type {Y.RelativePosition}
*/
const head = absolutePositionToRelativePosition(selection.head, ystate.type, ystate.binding.mapping);
if (
current.cursor == null ||
!Y.compareRelativePositions(Y.createRelativePositionFromJSON(current.cursor.anchor), anchor) ||
!Y.compareRelativePositions(Y.createRelativePositionFromJSON(current.cursor.head), head)
) {
awareness.setLocalStateField(cursorStateField, {
anchor,
head,
});
}
} else if (
current.cursor != null &&
relativePositionToAbsolutePosition(
ystate.doc,
ystate.type,
Y.createRelativePositionFromJSON(current.cursor.anchor),
ystate.binding.mapping
) !== null
) {
// delete cursor information if current cursor information is owned by this editor binding
awareness.setLocalStateField(cursorStateField, null);
}
};
awareness.on('change', awarenessListener);
view.dom.addEventListener('focusin', updateCursorInfo);
view.dom.addEventListener('focusout', updateCursorInfo);
return {
update: updateCursorInfo,
destroy: () => {
view.dom.removeEventListener('focusin', updateCursorInfo);
view.dom.removeEventListener('focusout', updateCursorInfo);
awareness.off('change', awarenessListener);
awareness.setLocalStateField(cursorStateField, null);
},
};
},
});

View File

@ -0,0 +1,22 @@
import { PluginKey } from 'prosemirror-state'; // eslint-disable-line
/**
* The unique prosemirror plugin key for syncPlugin
*
* @public
*/
export const ySyncPluginKey = new PluginKey('y-sync');
/**
* The unique prosemirror plugin key for undoPlugin
*
* @public
*/
export const yUndoPluginKey = new PluginKey('y-undo');
/**
* The unique prosemirror plugin key for cursorPlugin
*
* @public
*/
export const yCursorPluginKey = new PluginKey('yjs-cursor');

View File

@ -0,0 +1,921 @@
/**
* @module bindings/prosemirror
*/
import { simpleDiff } from 'lib0/diff';
import * as dom from 'lib0/dom';
import * as environment from 'lib0/environment';
import * as error from 'lib0/error';
import * as math from 'lib0/math';
import { createMutex } from 'lib0/mutex';
import * as object from 'lib0/object';
import * as random from 'lib0/random';
import * as set from 'lib0/set';
import * as PModel from 'prosemirror-model';
import { Plugin, TextSelection } from 'prosemirror-state'; // eslint-disable-line
import * as Y from 'yjs';
import { absolutePositionToRelativePosition, relativePositionToAbsolutePosition } from '../lib.js';
import { ySyncPluginKey } from './keys.js';
/**
* @param {Y.Item} item
* @param {Y.Snapshot} [snapshot]
*/
export const isVisible = (item, snapshot) =>
snapshot === undefined
? !item.deleted
: snapshot.sv.has(item.id.client) &&
/** @type {number} */ (snapshot.sv.get(item.id.client)) > item.id.clock &&
!Y.isDeleted(snapshot.ds, item.id);
/**
* Either a node if type is YXmlElement or an Array of text nodes if YXmlText
* @typedef {Map<Y.AbstractType, PModel.Node | Array<PModel.Node>>} ProsemirrorMapping
*/
/**
* @typedef {Object} ColorDef
* @property {string} ColorDef.light
* @property {string} ColorDef.dark
*/
/**
* @typedef {Object} YSyncOpts
* @property {Array<ColorDef>} [YSyncOpts.colors]
* @property {Map<string,ColorDef>} [YSyncOpts.colorMapping]
* @property {Y.PermanentUserData|null} [YSyncOpts.permanentUserData]
*/
/**
* @type {Array<ColorDef>}
*/
const defaultColors = [{ light: '#ecd44433', dark: '#ecd444' }];
/**
* @param {Map<string,ColorDef>} colorMapping
* @param {Array<ColorDef>} colors
* @param {string} user
* @return {ColorDef}
*/
const getUserColor = (colorMapping, colors, user) => {
// @todo do not hit the same color twice if possible
if (!colorMapping.has(user)) {
if (colorMapping.size < colors.length) {
const usedColors = set.create();
colorMapping.forEach((color) => usedColors.add(color));
colors = colors.filter((color) => !usedColors.has(color));
}
colorMapping.set(user, random.oneOf(colors));
}
return /** @type {ColorDef} */ (colorMapping.get(user));
};
/**
* 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 {Y.XmlFragment} yXmlFragment
* @param {YSyncOpts} opts
* @return {any} Returns a prosemirror plugin that binds to this type
*/
export const ySyncPlugin = (
yXmlFragment,
{ colors = defaultColors, colorMapping = new Map(), permanentUserData = null } = {}
) => {
let changedInitialContent = false;
const plugin = new Plugin({
props: {
editable: (state) => {
const syncState = ySyncPluginKey.getState(state);
return syncState.snapshot == null && syncState.prevSnapshot == null;
},
},
key: ySyncPluginKey,
state: {
init: (initargs, state) => {
return {
type: yXmlFragment,
doc: yXmlFragment.doc,
binding: null,
snapshot: null,
prevSnapshot: null,
isChangeOrigin: false,
colors,
colorMapping,
permanentUserData,
};
},
apply: (tr, pluginState) => {
const change = tr.getMeta(ySyncPluginKey);
if (change !== undefined) {
pluginState = Object.assign({}, pluginState);
for (const key in change) {
pluginState[key] = change[key];
}
}
// always set isChangeOrigin. If undefined, this is not change origin.
pluginState.isChangeOrigin = change !== undefined && !!change.isChangeOrigin;
if (pluginState.binding !== null) {
if (change !== undefined && (change.snapshot != null || change.prevSnapshot != null)) {
// snapshot changed, rerender next
setTimeout(() => {
if (change.restore == null) {
pluginState.binding._renderSnapshot(change.snapshot, change.prevSnapshot, pluginState);
} else {
pluginState.binding._renderSnapshot(change.snapshot, change.snapshot, pluginState);
// reset to current prosemirror state
delete pluginState.restore;
delete pluginState.snapshot;
delete pluginState.prevSnapshot;
pluginState.binding._prosemirrorChanged(pluginState.binding.prosemirrorView.state.doc);
}
}, 0);
}
}
return pluginState;
},
},
view: (view) => {
const binding = new ProsemirrorBinding(yXmlFragment, view);
// Make sure this is called in a separate context
setTimeout(() => {
binding._forceRerender();
view.dispatch(view.state.tr.setMeta(ySyncPluginKey, { binding }));
}, 0);
return {
update: () => {
const pluginState = plugin.getState(view.state);
if (pluginState.snapshot == null && pluginState.prevSnapshot == null) {
if (
changedInitialContent ||
view.state.doc.content.findDiffStart(view.state.doc.type.createAndFill().content) !== null
) {
changedInitialContent = true;
binding._prosemirrorChanged(view.state.doc);
}
}
},
destroy: () => {
binding.destroy();
},
};
},
});
return plugin;
};
/**
* @param {any} tr
* @param {any} relSel
* @param {ProsemirrorBinding} binding
*/
const restoreRelativeSelection = (tr, relSel, binding) => {
if (relSel !== null && relSel.anchor !== null && relSel.head !== null) {
const anchor = relativePositionToAbsolutePosition(binding.doc, binding.type, relSel.anchor, binding.mapping);
const head = relativePositionToAbsolutePosition(binding.doc, binding.type, relSel.head, binding.mapping);
if (anchor !== null && head !== null) {
tr = tr.setSelection(TextSelection.create(tr.doc, anchor, head));
}
}
};
export const getRelativeSelection = (pmbinding, state) => ({
anchor: absolutePositionToRelativePosition(state.selection.anchor, pmbinding.type, pmbinding.mapping),
head: absolutePositionToRelativePosition(state.selection.head, pmbinding.type, pmbinding.mapping),
});
/**
* Binding for prosemirror.
*
* @protected
*/
export class ProsemirrorBinding {
/**
* @param {Y.XmlFragment} yXmlFragment The bind source
* @param {any} 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);
/**
* @type {Y.Doc}
*/
// @ts-ignore
this.doc = yXmlFragment.doc;
/**
* current selection as relative positions in the Yjs model
*/
this.beforeTransactionSelection = null;
this.beforeAllTransactions = () => {
if (this.beforeTransactionSelection === null) {
this.beforeTransactionSelection = getRelativeSelection(this, prosemirrorView.state);
}
};
this.afterAllTransactions = () => {
this.beforeTransactionSelection = null;
};
this.doc.on('beforeAllTransactions', this.beforeAllTransactions);
this.doc.on('afterAllTransactions', this.afterAllTransactions);
yXmlFragment.observeDeep(this._observeFunction);
this._domSelectionInView = null;
}
_isLocalCursorInView() {
if (!this.prosemirrorView.hasFocus()) return false;
if (environment.isBrowser && this._domSelectionInView === null) {
// Calculate the domSelectionInView and clear by next tick after all events are finished
setTimeout(() => {
this._domSelectionInView = null;
}, 0);
this._domSelectionInView = this._isDomSelectionInView();
}
return this._domSelectionInView;
}
_isDomSelectionInView() {
const selection = this.prosemirrorView._root.getSelection();
const range = this.prosemirrorView._root.createRange();
range.setStart(selection.anchorNode, selection.anchorOffset);
range.setEnd(selection.focusNode, selection.focusOffset);
// This is a workaround for an edgecase where getBoundingClientRect will
// return zero values if the selection is collapsed at the start of a newline
// see reference here: https://stackoverflow.com/a/59780954
const rects = range.getClientRects();
if (rects.length === 0) {
// probably buggy newline behavior, explicitly select the node contents
if (range.startContainer && range.collapsed) {
range.selectNodeContents(range.startContainer);
}
}
const bounding = range.getBoundingClientRect();
const documentElement = dom.doc.documentElement;
return (
bounding.bottom >= 0 &&
bounding.right >= 0 &&
bounding.left <= (window.innerWidth || documentElement.clientWidth || 0) &&
bounding.top <= (window.innerHeight || documentElement.clientHeight || 0)
);
}
renderSnapshot(snapshot, prevSnapshot) {
if (!prevSnapshot) {
prevSnapshot = Y.createSnapshot(Y.createDeleteSet(), new Map());
}
this.prosemirrorView.dispatch(this.prosemirrorView.state.tr.setMeta(ySyncPluginKey, { snapshot, prevSnapshot }));
}
unrenderSnapshot() {
this.mapping = new Map();
this.mux(() => {
const fragmentContent = this.type
.toArray()
.map((t) =>
createNodeFromYElement(/** @type {Y.XmlElement} */ (t), this.prosemirrorView.state.schema, this.mapping)
)
.filter((n) => n !== null);
// @ts-ignore
const tr = this.prosemirrorView.state.tr.replace(
0,
this.prosemirrorView.state.doc.content.size,
new PModel.Slice(new PModel.Fragment(fragmentContent), 0, 0)
);
tr.setMeta(ySyncPluginKey, { snapshot: null, prevSnapshot: null });
this.prosemirrorView.dispatch(tr);
});
}
_forceRerender() {
this.mapping = new Map();
this.mux(() => {
const fragmentContent = this.type
.toArray()
.map((t) =>
createNodeFromYElement(/** @type {Y.XmlElement} */ (t), this.prosemirrorView.state.schema, this.mapping)
)
.filter((n) => n !== null);
// @ts-ignore
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.setMeta(ySyncPluginKey, { isChangeOrigin: true }));
});
}
/**
* @param {Y.Snapshot} snapshot
* @param {Y.Snapshot} prevSnapshot
* @param {Object} pluginState
*/
_renderSnapshot(snapshot, prevSnapshot, pluginState) {
if (!snapshot) {
snapshot = Y.snapshot(this.doc);
}
// clear mapping because we are going to rerender
this.mapping = new Map();
this.mux(() => {
this.doc.transact((transaction) => {
// before rendering, we are going to sanitize ops and split deleted ops
// if they were deleted by seperate users.
const pud = pluginState.permanentUserData;
if (pud) {
pud.dss.forEach((ds) => {
Y.iterateDeletedStructs(transaction, ds, (item) => {});
});
}
const computeYChange = (type, id) => {
const user = type === 'added' ? pud.getUserByClientId(id.client) : pud.getUserByDeletedId(id);
return {
user,
type,
color: getUserColor(pluginState.colorMapping, pluginState.colors, user),
};
};
// Create document fragment and render
const fragmentContent = Y.typeListToArraySnapshot(this.type, new Y.Snapshot(prevSnapshot.ds, snapshot.sv))
.map((t) => {
if (!t._item.deleted || isVisible(t._item, snapshot) || isVisible(t._item, prevSnapshot)) {
return createNodeFromYElement(
t,
this.prosemirrorView.state.schema,
new Map(),
snapshot,
prevSnapshot,
computeYChange
);
} else {
// No need to render elements that are not visible by either snapshot.
// If a client adds and deletes content in the same snapshot the element is not visible by either snapshot.
return null;
}
})
.filter((n) => n !== null);
// @ts-ignore
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.setMeta(ySyncPluginKey, { isChangeOrigin: true }));
}, ySyncPluginKey);
});
}
/**
* @param {Array<Y.YEvent>} events
* @param {Y.Transaction} transaction
*/
_typeChanged(events, transaction) {
const syncState = ySyncPluginKey.getState(this.prosemirrorView.state);
if (events.length === 0 || syncState.snapshot != null || syncState.prevSnapshot != null) {
// drop out if snapshot is active
this.renderSnapshot(syncState.snapshot, syncState.prevSnapshot);
return;
}
this.mux(() => {
/**
* @param {any} _
* @param {Y.AbstractType} type
*/
const delType = (_, type) => this.mapping.delete(type);
Y.iterateDeletedStructs(
transaction,
transaction.deleteSet,
(struct) =>
struct.constructor === Y.Item &&
this.mapping.delete(/** @type {Y.ContentType} */ (/** @type {Y.Item} */ (struct).content).type)
);
transaction.changed.forEach(delType);
transaction.changedParentTypes.forEach(delType);
const fragmentContent = this.type
.toArray()
.map((t) =>
createNodeIfNotExists(
/** @type {Y.XmlElement | Y.XmlHook} */ (t),
this.prosemirrorView.state.schema,
this.mapping
)
)
.filter((n) => n !== null);
// @ts-ignore
let tr = this.prosemirrorView.state.tr.replace(
0,
this.prosemirrorView.state.doc.content.size,
new PModel.Slice(new PModel.Fragment(fragmentContent), 0, 0)
);
restoreRelativeSelection(tr, this.beforeTransactionSelection, this);
tr = tr.setMeta(ySyncPluginKey, { isChangeOrigin: true });
if (this.beforeTransactionSelection !== null && this._isLocalCursorInView()) {
tr.scrollIntoView();
}
this.prosemirrorView.dispatch(tr);
});
}
_prosemirrorChanged(doc) {
this.mux(() => {
this.doc.transact(() => {
updateYFragment(this.doc, this.type, doc, this.mapping);
this.beforeTransactionSelection = getRelativeSelection(this, this.prosemirrorView.state);
}, ySyncPluginKey);
});
}
destroy() {
this.type.unobserveDeep(this._observeFunction);
this.doc.off('beforeAllTransactions', this.beforeAllTransactions);
this.doc.off('afterAllTransactions', this.afterAllTransactions);
}
}
/**
* @private
* @param {Y.XmlElement | Y.XmlHook} el
* @param {PModel.Schema} schema
* @param {ProsemirrorMapping} mapping
* @param {Y.Snapshot} [snapshot]
* @param {Y.Snapshot} [prevSnapshot]
* @param {function('removed' | 'added', Y.ID):any} [computeYChange]
* @return {PModel.Node | null}
*/
const createNodeIfNotExists = (el, schema, mapping, snapshot, prevSnapshot, computeYChange) => {
const node = /** @type {PModel.Node} */ (mapping.get(el));
if (node === undefined) {
if (el instanceof Y.XmlElement) {
return createNodeFromYElement(el, schema, mapping, snapshot, prevSnapshot, computeYChange);
} else {
throw error.methodUnimplemented(); // we are currently not handling hooks
}
}
return node;
};
/**
* @private
* @param {Y.XmlElement} el
* @param {any} schema
* @param {ProsemirrorMapping} mapping
* @param {Y.Snapshot} [snapshot]
* @param {Y.Snapshot} [prevSnapshot]
* @param {function('removed' | 'added', Y.ID):any} [computeYChange]
* @return {PModel.Node | null} Returns node if node could be created. Otherwise it deletes the yjs type and returns null
*/
const createNodeFromYElement = (el, schema, mapping, snapshot, prevSnapshot, computeYChange) => {
const children = [];
const createChildren = (type) => {
if (type.constructor === Y.XmlElement) {
const n = createNodeIfNotExists(type, schema, mapping, snapshot, prevSnapshot, computeYChange);
if (n !== null) {
children.push(n);
}
} else {
const ns = createTextNodesFromYText(type, schema, mapping, snapshot, prevSnapshot, computeYChange);
if (ns !== null) {
ns.forEach((textchild) => {
if (textchild !== null) {
children.push(textchild);
}
});
}
}
};
if (snapshot === undefined || prevSnapshot === undefined) {
el.toArray().forEach(createChildren);
} else {
Y.typeListToArraySnapshot(el, new Y.Snapshot(prevSnapshot.ds, snapshot.sv)).forEach(createChildren);
}
try {
const attrs = el.getAttributes(snapshot);
if (snapshot !== undefined) {
if (!isVisible(/** @type {Y.Item} */ (el._item), snapshot)) {
attrs.ychange = computeYChange
? computeYChange('removed', /** @type {Y.Item} */ (el._item).id)
: { type: 'removed' };
} else if (!isVisible(/** @type {Y.Item} */ (el._item), prevSnapshot)) {
attrs.ychange = computeYChange
? computeYChange('added', /** @type {Y.Item} */ (el._item).id)
: { type: 'added' };
}
}
const node = schema.node(el.nodeName, attrs, children);
mapping.set(el, node);
return node;
} catch (e) {
// an error occured while creating the node. This is probably a result of a concurrent action.
/** @type {Y.Doc} */ (el.doc).transact((transaction) => {
/** @type {Y.Item} */ (el._item).delete(transaction);
}, ySyncPluginKey);
mapping.delete(el);
return null;
}
};
/**
* @private
* @param {Y.XmlText} text
* @param {any} schema
* @param {ProsemirrorMapping} mapping
* @param {Y.Snapshot} [snapshot]
* @param {Y.Snapshot} [prevSnapshot]
* @param {function('removed' | 'added', Y.ID):any} [computeYChange]
* @return {Array<PModel.Node>|null}
*/
const createTextNodesFromYText = (text, schema, mapping, snapshot, prevSnapshot, computeYChange) => {
const nodes = [];
const deltas = text.toDelta(snapshot, prevSnapshot, computeYChange);
try {
for (let i = 0; i < deltas.length; i++) {
const delta = deltas[i];
const marks = [];
for (const markName in delta.attributes) {
marks.push(schema.mark(markName, delta.attributes[markName]));
}
nodes.push(schema.text(delta.insert, marks));
}
} catch (e) {
// an error occured while creating the node. This is probably a result of a concurrent action.
/** @type {Y.Doc} */ (text.doc).transact((transaction) => {
/** @type {Y.Item} */ (text._item).delete(transaction);
}, ySyncPluginKey);
return null;
}
// @ts-ignore
return nodes;
};
/**
* @private
* @param {Array<any>} nodes prosemirror node
* @param {ProsemirrorMapping} mapping
* @return {Y.XmlText}
*/
const createTypeFromTextNodes = (nodes, mapping) => {
const type = new Y.XmlText();
const delta = nodes.map((node) => ({
// @ts-ignore
insert: node.text,
attributes: marksToAttributes(node.marks),
}));
type.applyDelta(delta);
mapping.set(type, nodes);
return type;
};
/**
* @private
* @param {any} node prosemirror node
* @param {ProsemirrorMapping} mapping
* @return {Y.XmlElement}
*/
const createTypeFromElementNode = (node, mapping) => {
const type = new Y.XmlElement(node.type.name);
for (const key in node.attrs) {
const val = node.attrs[key];
if (val !== null && key !== 'ychange') {
type.setAttribute(key, val);
}
}
type.insert(
0,
normalizePNodeContent(node).map((n) => createTypeFromTextOrElementNode(n, mapping))
);
mapping.set(type, node);
return type;
};
/**
* @private
* @param {PModel.Node|Array<PModel.Node>} node prosemirror text node
* @param {ProsemirrorMapping} mapping
* @return {Y.XmlElement|Y.XmlText}
*/
const createTypeFromTextOrElementNode = (node, mapping) =>
node instanceof Array ? createTypeFromTextNodes(node, mapping) : createTypeFromElementNode(node, mapping);
const isObject = (val) => typeof val === 'object' && val !== null;
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];
const l = pattrs[key];
const r = yattrs[key];
eq = key === 'ychange' || l === r || (isObject(l) && isObject(r) && equalAttrs(l, r));
}
return eq;
};
/**
* @typedef {Array<Array<PModel.Node>|PModel.Node>} NormalizedPNodeContent
*/
/**
* @param {any} pnode
* @return {NormalizedPNodeContent}
*/
const normalizePNodeContent = (pnode) => {
const c = pnode.content.content;
const res = [];
for (let i = 0; i < c.length; i++) {
const n = c[i];
if (n.isText) {
const textNodes = [];
for (let tnode = c[i]; i < c.length && tnode.isText; tnode = c[++i]) {
textNodes.push(tnode);
}
i--;
res.push(textNodes);
} else {
res.push(n);
}
}
return res;
};
/**
* @param {Y.XmlText} ytext
* @param {Array<any>} ptexts
*/
const equalYTextPText = (ytext, ptexts) => {
const delta = ytext.toDelta();
return (
delta.length === ptexts.length &&
delta.every(
(d, i) =>
d.insert === /** @type {any} */ (ptexts[i]).text &&
object.keys(d.attributes || {}).length === ptexts[i].marks.length &&
ptexts[i].marks.every((mark) => equalAttrs(d.attributes[mark.type.name] || {}, mark.attrs))
)
);
};
/**
* @param {Y.XmlElement|Y.XmlText|Y.XmlHook} ytype
* @param {any|Array<any>} pnode
*/
const equalYTypePNode = (ytype, pnode) => {
if (ytype instanceof Y.XmlElement && !(pnode instanceof Array) && matchNodeName(ytype, pnode)) {
const normalizedContent = normalizePNodeContent(pnode);
return (
ytype._length === normalizedContent.length &&
equalAttrs(ytype.getAttributes(), pnode.attrs) &&
ytype.toArray().every((ychild, i) => equalYTypePNode(ychild, normalizedContent[i]))
);
}
return ytype instanceof Y.XmlText && pnode instanceof Array && equalYTextPText(ytype, pnode);
};
/**
* @param {PModel.Node | Array<PModel.Node> | undefined} mapped
* @param {PModel.Node | Array<PModel.Node>} pcontent
*/
const mappedIdentity = (mapped, pcontent) =>
mapped === pcontent ||
(mapped instanceof Array &&
pcontent instanceof Array &&
mapped.length === pcontent.length &&
mapped.every((a, i) => pcontent[i] === a));
/**
* @param {Y.XmlElement} ytype
* @param {PModel.Node} pnode
* @param {ProsemirrorMapping} mapping
* @return {{ foundMappedChild: boolean, equalityFactor: number }}
*/
const computeChildEqualityFactor = (ytype, pnode, mapping) => {
const yChildren = ytype.toArray();
const pChildren = normalizePNodeContent(pnode);
const pChildCnt = pChildren.length;
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 = pChildren[left];
if (mappedIdentity(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 = pChildren[pChildCnt - right - 1];
if (mappedIdentity(mapping.get(rightY), rightP)) {
foundMappedChild = true;
} else if (!equalYTypePNode(rightY, rightP)) {
break;
}
}
return {
equalityFactor: left + right,
foundMappedChild,
};
};
const ytextTrans = (ytext) => {
let str = '';
/**
* @type {Y.Item|null}
*/
let n = ytext._start;
const nAttrs = {};
while (n !== null) {
if (!n.deleted) {
if (n.countable && n.content instanceof Y.ContentString) {
str += n.content.str;
} else if (n.content instanceof Y.ContentFormat) {
nAttrs[n.content.key] = null;
}
}
n = n.right;
}
return {
str,
nAttrs,
};
};
/**
* @todo test this more
*
* @param {Y.Text} ytext
* @param {Array<any>} ptexts
* @param {ProsemirrorMapping} mapping
*/
const updateYText = (ytext, ptexts, mapping) => {
mapping.set(ytext, ptexts);
const { nAttrs, str } = ytextTrans(ytext);
const content = ptexts.map((p) => ({
insert: /** @type {any} */ (p).text,
attributes: Object.assign({}, nAttrs, marksToAttributes(p.marks)),
}));
const { insert, remove, index } = simpleDiff(str, content.map((c) => c.insert).join(''));
ytext.delete(index, remove);
ytext.insert(index, insert);
ytext.applyDelta(content.map((c) => ({ retain: c.insert.length, attributes: c.attributes })));
};
const marksToAttributes = (marks) => {
const pattrs = {};
marks.forEach((mark) => {
if (mark.type.name !== 'ychange') {
pattrs[mark.type.name] = mark.attrs;
}
});
return pattrs;
};
/**
* @private
* @param {Y.Doc} y
* @param {Y.XmlFragment} yDomFragment
* @param {any} pNode
* @param {ProsemirrorMapping} mapping
*/
export const updateYFragment = (y, yDomFragment, pNode, mapping) => {
if (yDomFragment instanceof Y.XmlElement && yDomFragment.nodeName !== pNode.type.name) {
throw new Error('node name mismatch!');
}
mapping.set(yDomFragment, pNode);
// update attributes
if (yDomFragment instanceof Y.XmlElement) {
const yDomAttrs = yDomFragment.getAttributes();
const pAttrs = pNode.attrs;
for (const key in pAttrs) {
if (pAttrs[key] !== null) {
if (yDomAttrs[key] !== pAttrs[key] && key !== 'ychange') {
yDomFragment.setAttribute(key, pAttrs[key]);
}
} else {
yDomFragment.removeAttribute(key);
}
}
// remove all keys that are no longer in pAttrs
for (const key in yDomAttrs) {
if (pAttrs[key] === undefined) {
yDomFragment.removeAttribute(key);
}
}
}
// update children
const pChildren = normalizePNodeContent(pNode);
const pChildCnt = pChildren.length;
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 = pChildren[left];
if (!mappedIdentity(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 + 1 < minCnt; right++) {
const rightY = yChildren[yChildCnt - right - 1];
const rightP = pChildren[pChildCnt - right - 1];
if (!mappedIdentity(mapping.get(rightY), rightP)) {
if (equalYTypePNode(rightY, rightP)) {
// update mapping
mapping.set(rightY, rightP);
} else {
break;
}
}
}
y.transact(() => {
// try to compare and update
while (yChildCnt - left - right > 0 && pChildCnt - left - right > 0) {
const leftY = yChildren[left];
const leftP = pChildren[left];
const rightY = yChildren[yChildCnt - right - 1];
const rightP = pChildren[pChildCnt - right - 1];
if (leftY instanceof Y.XmlText && leftP instanceof Array) {
if (!equalYTextPText(leftY, leftP)) {
updateYText(leftY, leftP, mapping);
}
left += 1;
} else {
let updateLeft = leftY instanceof Y.XmlElement && matchNodeName(leftY, leftP);
let updateRight = rightY instanceof Y.XmlElement && matchNodeName(rightY, rightP);
if (updateLeft && updateRight) {
// decide which which element to update
const equalityLeft = computeChildEqualityFactor(
/** @type {Y.XmlElement} */ (leftY),
/** @type {PModel.Node} */ (leftP),
mapping
);
const equalityRight = computeChildEqualityFactor(
/** @type {Y.XmlElement} */ (rightY),
/** @type {PModel.Node} */ (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(y, /** @type {Y.XmlFragment} */ (leftY), /** @type {PModel.Node} */ (leftP), mapping);
left += 1;
} else if (updateRight) {
updateYFragment(y, /** @type {Y.XmlFragment} */ (rightY), /** @type {PModel.Node} */ (rightP), mapping);
right += 1;
} else {
yDomFragment.delete(left, 1);
yDomFragment.insert(left, [createTypeFromTextOrElementNode(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(createTypeFromTextOrElementNode(pChildren[i], mapping));
}
yDomFragment.insert(left, ins);
}
}, ySyncPluginKey);
};
/**
* @function
* @param {Y.XmlElement} yElement
* @param {any} pNode Prosemirror Node
*/
const matchNodeName = (yElement, pNode) => !(pNode instanceof Array) && yElement.nodeName === pNode.type.name;

View File

@ -0,0 +1,100 @@
import { Plugin } from 'prosemirror-state'; // eslint-disable-line
import { getRelativeSelection } from './sync-plugin.js';
import { UndoManager, Item, ContentType, XmlElement, Text } from 'yjs';
import { yUndoPluginKey, ySyncPluginKey } from './keys.js';
export const undo = (state) => {
const undoManager = yUndoPluginKey.getState(state).undoManager;
if (undoManager != null) {
undoManager.undo();
return true;
}
};
export const redo = (state) => {
const undoManager = yUndoPluginKey.getState(state).undoManager;
if (undoManager != null) {
undoManager.redo();
return true;
}
};
export const defaultProtectedNodes = new Set(['paragraph']);
export const defaultDeleteFilter = (item, protectedNodes) =>
!(item instanceof Item) ||
!(item.content instanceof ContentType) ||
!(
item.content.type instanceof Text ||
(item.content.type instanceof XmlElement && protectedNodes.has(item.content.type.nodeName))
) ||
item.content.type._length === 0;
export const yUndoPlugin = ({ protectedNodes = defaultProtectedNodes, trackedOrigins = [], undoManager = null } = {}) =>
new Plugin({
key: yUndoPluginKey,
state: {
init: (initargs, state) => {
// TODO: check if plugin order matches and fix
const ystate = ySyncPluginKey.getState(state);
const _undoManager =
undoManager ||
new UndoManager(ystate.type, {
trackedOrigins: new Set([ySyncPluginKey].concat(trackedOrigins)),
deleteFilter: (item) => defaultDeleteFilter(item, protectedNodes),
});
return {
undoManager: _undoManager,
prevSel: null,
hasUndoOps: _undoManager.undoStack.length > 0,
hasRedoOps: _undoManager.redoStack.length > 0,
};
},
apply: (tr, val, oldState, state) => {
const binding = ySyncPluginKey.getState(state).binding;
const undoManager = val.undoManager;
const hasUndoOps = undoManager.undoStack.length > 0;
const hasRedoOps = undoManager.redoStack.length > 0;
if (binding) {
return {
undoManager,
prevSel: getRelativeSelection(binding, oldState),
hasUndoOps,
hasRedoOps,
};
} else {
if (hasUndoOps !== val.hasUndoOps || hasRedoOps !== val.hasRedoOps) {
return Object.assign({}, val, {
hasUndoOps: undoManager.undoStack.length > 0,
hasRedoOps: undoManager.redoStack.length > 0,
});
} else {
// nothing changed
return val;
}
}
},
},
view: (view) => {
const ystate = ySyncPluginKey.getState(view.state);
const undoManager = yUndoPluginKey.getState(view.state).undoManager;
undoManager.on('stack-item-added', ({ stackItem }) => {
const binding = ystate.binding;
if (binding) {
stackItem.meta.set(binding, yUndoPluginKey.getState(view.state).prevSel);
}
});
undoManager.on('stack-item-popped', ({ stackItem }) => {
const binding = ystate.binding;
if (binding) {
binding.beforeTransactionSelection = stackItem.meta.get(binding) || binding.beforeTransactionSelection;
}
});
return {
destroy: () => {
undoManager.destroy();
},
};
},
});

View File

@ -0,0 +1,13 @@
export {
absolutePositionToRelativePosition,
prosemirrorJSONToYDoc,
prosemirrorToYDoc,
relativePositionToAbsolutePosition,
setMeta,
yDocToProsemirror,
yDocToProsemirrorJSON,
} from './lib.js';
export * from './plugins/cursor-plugin.js';
export * from './plugins/keys.js';
export { getRelativeSelection, isVisible, ProsemirrorBinding, ySyncPlugin } from './plugins/sync-plugin.js';
export * from './plugins/undo-plugin.js';

View File

@ -5,7 +5,7 @@ import { debounce } from 'helpers/debounce';
import { useToggle } from 'hooks/use-toggle'; import { useToggle } from 'hooks/use-toggle';
import { SecureDocumentIllustration } from 'illustrations/secure-document'; import { SecureDocumentIllustration } from 'illustrations/secure-document';
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { IndexeddbPersistence } from 'y-indexeddb'; import { IndexeddbPersistence } from 'tiptap/core/y-indexeddb';
import { Editor } from '../../react'; import { Editor } from '../../react';
import { EditorInstance } from './editor'; import { EditorInstance } from './editor';

File diff suppressed because it is too large Load Diff