Collaborative rich text editing
Instead of sharing plain strings or Text instances, what if you want to create a collaborative, (google docs style) rich text editing experience?
You can bind SyncedStore to the rich text editor of your choice. In most cases, you'll need to bind it to a XmlFragment
on your store. Here's is an example using TipTap and SyncedStore:
TipTap example
import React, { useState, useCallback, useEffect } from "react"; import { store, webrtcProvider } from "./store"; import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import Collaboration from "@tiptap/extension-collaboration"; import CollaborationCursor from "@tiptap/extension-collaboration-cursor"; const colors = ["#958DF1", "#F98181", "#FBBC88", "#FAF594", "#70CFF8", "#94FADB", "#B9F18D"]; const names = ["Lea Thompson", "Cyndi Lauper", "Tom Cruise", "Madonna"]; const getRandomElement = (list) => list[Math.floor(Math.random() * list.length)]; const getRandomColor = () => getRandomElement(colors); const getRandomName = () => getRandomElement(names); export default () => { const editor = useEditor({ extensions: [ StarterKit, Placeholder.configure({ placeholder: "Write something …", }), Collaboration.configure({ fragment: store.fragment, }), CollaborationCursor.configure({ provider: webrtcProvider, user: { name: getRandomName(), color: getRandomColor() }, }), ], }); return ( <div className="editor"> <MenuBar editor={editor} /> <EditorContent editor={editor} /> </div> ); }; const MenuBar = ({ editor }) => { if (!editor) { return null; } return ( <> <button onClick={() => editor.chain().focus().toggleBold().run()} className={editor.isActive("bold") ? "is-active" : ""} > bold </button> <button onClick={() => editor.chain().focus().toggleItalic().run()} className={editor.isActive("italic") ? "is-active" : ""} > italic </button> <button onClick={() => editor.chain().focus().toggleStrike().run()} className={editor.isActive("strike") ? "is-active" : ""} > strike </button> <button onClick={() => editor.chain().focus().toggleCode().run()} className={editor.isActive("code") ? "is-active" : ""} > code </button> <button onClick={() => editor.chain().focus().unsetAllMarks().run()}>clear marks</button> <button onClick={() => editor.chain().focus().clearNodes().run()}>clear nodes</button> <button onClick={() => editor.chain().focus().setParagraph().run()} className={editor.isActive("paragraph") ? "is-active" : ""} > paragraph </button> <button onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} className={editor.isActive("heading", { level: 1 }) ? "is-active" : ""} > h1 </button> <button onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} className={editor.isActive("heading", { level: 2 }) ? "is-active" : ""} > h2 </button> <button onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} className={editor.isActive("heading", { level: 3 }) ? "is-active" : ""} > h3 </button> <button onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()} className={editor.isActive("heading", { level: 4 }) ? "is-active" : ""} > h4 </button> <button onClick={() => editor.chain().focus().toggleHeading({ level: 5 }).run()} className={editor.isActive("heading", { level: 5 }) ? "is-active" : ""} > h5 </button> <button onClick={() => editor.chain().focus().toggleHeading({ level: 6 }).run()} className={editor.isActive("heading", { level: 6 }) ? "is-active" : ""} > h6 </button> <button onClick={() => editor.chain().focus().toggleBulletList().run()} className={editor.isActive("bulletList") ? "is-active" : ""} > bullet list </button> <button onClick={() => editor.chain().focus().toggleOrderedList().run()} className={editor.isActive("orderedList") ? "is-active" : ""} > ordered list </button> <button onClick={() => editor.chain().focus().toggleCodeBlock().run()} className={editor.isActive("codeBlock") ? "is-active" : ""} > code block </button> <button onClick={() => editor.chain().focus().toggleBlockquote().run()} className={editor.isActive("blockquote") ? "is-active" : ""} > blockquote </button> <button onClick={() => editor.chain().focus().setHorizontalRule().run()}>horizontal rule</button> <button onClick={() => editor.chain().focus().setHardBreak().run()}>hard break</button> <button onClick={() => editor.chain().focus().undo().run()}>undo</button> <button onClick={() => editor.chain().focus().redo().run()}>redo</button> </> ); };
Libraries for different editors
The above example uses TipTap, which is a Prosemirror-based editor, but you might be interested in one of the other editors and bindings as well:
Library | Binding |
---|---|
TipTap (prosemirror based) | built in |
ProseMirror | y-prosemirror |
Quill | y-quill |
CodeMirror | y-codemirror |
Monaco | y-monaco |
Slate | slate-yjs |
View example setup for Prosemirror
import syncedStore from "@syncedstore/core";
import { ySyncPlugin } from "y-prosemirror";
const doc = new Y.Doc();
export const store = syncedStore({ fragment: "xml" });
// When you set up your ProseMirror instance,
// hook up store.fragment to the y-prosemirror plugin
EditorState.create({
plugins: [
ySyncPlugin(store.fragment),
// ... other plugins
],
// ... remaining prosemirror setup
});
(The rest is similar to the documentation of y-prosemirror )