Collaborative editing (#1660)

This commit is contained in:
Tom Moor
2021-09-10 22:46:57 -07:00
committed by GitHub
parent 0a998789a3
commit 801f6681ba
144 changed files with 3552 additions and 310 deletions

View File

@@ -0,0 +1 @@
web: yarn start --services=collaboration

View File

@@ -0,0 +1,46 @@
// @flow
import { AuthenticationError } from "../errors";
import { Document } from "../models";
import policy from "../policies";
import { getUserForJWT } from "../utils/jwt";
const { can } = policy;
export default class Authentication {
async onAuthenticate({
connection,
token,
documentName,
}: {
connection: { readOnly: boolean },
token: string,
documentName: string,
}) {
// allows for different entity types to use this multiplayer provider later
const [, documentId] = documentName.split(".");
if (!token) {
throw new AuthenticationError("Authentication required");
}
const user = await getUserForJWT(token);
if (user.isSuspended) {
throw new AuthenticationError("Account suspended");
}
const document = await Document.findByPk(documentId, { userId: user.id });
if (!can(user, "read", document)) {
throw new AuthenticationError("Authorization required");
}
// set document to read only for the current user, thus changes will not be
// accepted and synced to other clients
if (!can(user, "update", document)) {
connection.readOnly = true;
}
return {
user,
};
}
}

View File

@@ -0,0 +1,71 @@
// @flow
import debug from "debug";
import { debounce } from "lodash";
import * as Y from "yjs";
import documentUpdater from "../commands/documentUpdater";
import { Document, User } from "../models";
import markdownToYDoc from "./utils/markdownToYDoc";
const log = debug("server");
const DELAY = 3000;
export default class Persistence {
async onCreateDocument({
documentName,
...data
}: {
documentName: string,
document: Y.Doc,
}) {
const [, documentId] = documentName.split(".");
const fieldName = "default";
// Check if the given field already exists in the given y-doc. This is import
// so we don't import a document fresh if it exists already.
if (!data.document.isEmpty(fieldName)) {
return;
}
const document = await Document.findByPk(documentId);
if (document.state) {
const ydoc = new Y.Doc();
log(`Document ${documentId} is already in state`);
Y.applyUpdate(ydoc, document.state);
return ydoc;
}
log(`Document ${documentId} is not in state, creating state from markdown`);
const ydoc = markdownToYDoc(document.text, fieldName);
const state = Y.encodeStateAsUpdate(ydoc);
await document.update({ state: Buffer.from(state) }, { hooks: false });
return ydoc;
}
onChange = debounce(
async ({
document,
context,
documentName,
}: {
document: Y.Doc,
context: { user: User },
documentName: string,
}) => {
const [, documentId] = documentName.split(".");
log(`persisting ${documentId}`);
await documentUpdater({
documentId,
ydoc: document,
userId: context.user.id,
});
},
DELAY,
{
maxWait: DELAY * 3,
}
);
}

View File

@@ -0,0 +1,42 @@
// @flow
import { Node, Fragment } from "prosemirror-model";
import { parser, schema } from "rich-markdown-editor";
import { prosemirrorToYDoc } from "y-prosemirror";
import * as Y from "yjs";
import embeds from "../../../shared/embeds";
export default function markdownToYDoc(
markdown: string,
fieldName?: string = "default"
): Y.Doc {
let node = parser.parse(markdown);
// in rich-markdown-editor embeds were created at runtime by converting links
// into embeds where they match. Because we're converting to a CRDT structure
// on the server we need to mimic this behavior.
function urlsToEmbeds(node: Node): Node {
if (node.type.name === "paragraph") {
for (const textNode of node.content.content) {
for (const embed of embeds) {
if (textNode.text && embed.matcher(textNode.text)) {
return schema.nodes.embed.createAndFill({
href: textNode.text,
});
}
}
}
}
if (node.content) {
const contentAsArray =
node.content instanceof Fragment ? node.content.content : node.content;
node.content = Fragment.fromArray(contentAsArray.map(urlsToEmbeds));
}
return node;
}
node = urlsToEmbeds(node);
return prosemirrorToYDoc(node, fieldName);
}