This open-source library listens to changes in the HTML
structure of the editing area (contenteditable root node), uses MutationObserver
to reverse-engineer the data structure, and connects and interacts with Yjs through WebSocket
to achieve multi-user collaborative editing.
yarn add yjs @aomao/plugin-yjs @aomao/plugin-yjs-websocket
import { withYjs, YjsEditor, YCursorEditor, CursorStateChangeEvent, CursorData } from '@aomao/plugin-yjs';import { WebsocketProvider } from '@aomao/plugin-yjs-websocket';// Initialize yjs docconst doc = new Y.Doc()// Initialize WebsocketProviderconst provider = new WebsocketProvider('server url', 'document id', doc, { connect: false })const handleStatus = (event: Record<'status', 'connecting' | 'connected' | 'disconnected'>) => {if (!YjsEditor.isYjsEditor(engine)) return;// Handle relevant connection statusconst { status } = event;if(status === 'connected') {// ConnectedYjsEditor.connect(engine)} else if(status === 'disconnected') {// DisconnectedYjsEditor.disconnect(engine)} else if(status === 'connecting') {// Connecting}};if (provider) provider.on('status', handleStatus);// Get the share type of the yjs document. Here `content` represents the root node of the document, which needs to be consistent with the server. If you want to modify it, please keep it consistent between the client and server.const sharedType = doc.get('content', Y.XmlElement) as Y.XmlElement;// Use yjs plugin// provider.awareness is the awareness plugin of yjs, used to synchronize cursorswithYjs(engine, sharedType, provider.awareness, {data: {name: 'Name of the current collaborative user',color: 'Color of the current collaborative user',avatar?: 'Optional, avatar of the current collaborative user',... // Other custom data},});
Listen for cursor state changes of collaborators
// added: added collaborators, removed: removed collaborators, updated: updated collaboratorsconst handleCursorChange = ({ added, removed, updated }: CursorStateChangeEvent) => {if (added.length > 0) {for (const id of added) {// Get information of the collaboratorconst addedUser = YCursorEditor.cursorState(e, id);... // Custom handling of added collaborators}}if (removed.length > 0) {for (const id of removed) {... // Custom handling of removed collaborators}}// Cursor information of a collaborator will trigger this event when it is updated, and it is relatively frequent. If you only want to display the current information of all collaborative users, added and removed are enoughif (updated.length > 0) {for (const id of updated) {... // Custom handling of updated collaborators}}};// Subscribe to cursor state changesYCursorEditor.on(e, 'change', handleCursorChange);
@aomao/plugin-yjs-websocket
also provides an implementation of nodejs
server that can be used directly
yarn add yjs @aomao/plugin-yjs-websocket
Simply configure it and use it
import startServer from '@aomao/plugin-yjs-websocket/server';// Start the server, the default port is 1234, and leveldb is used as the databasestartServer();
Related configuration
startServer({// Host to listen on, default is 0.0.0.0host: string;// Port to listen on, default is 1234port: number;// Custom authentication, connection will be terminated if code !== 200 is returned// The document ID needs to be returned, and by default, it is extracted from the ws link in the format of ws:domain.com/docname, where docname is the document ID.auth?: (request: http.IncomingMessage, ws: WebSocket) => Promise<void | { code: number; data: string |// http server request listenerrequestListener?: http.RequestListener;Buffer }>;// Persistence options, false means no persistence/*** Default is leveldb* {* provider: 'leveldb';* dir?: string; // leveldb directory, default is ./db* }* mongodb configuration* {* provider: 'mongodb';* url* }*/persistenceOptions?: PersistenceOptions | false;// Document content field, default is "content"contentField?: string;// Update callbackcallback?: UpdateCallback;})
auth
Custom authenticationThe connection will be terminated when code !== 200.
The document ID needs to be returned, and by default, it is extracted from the ws link in the format of ws:domain.com/docname, where docname is the document ID.
const auth = async (request: http.IncomingMessage, ws: WebSocket) => {const { url } = request;const docname = url.split('/').pop();if (!docname) return { code: 400, data: 'Not found' };return docname;};startServer({ auth });
requestListener
is used for customizing the request listener of the http server, which can be used for custom routing.const app = express();app.get('/doc/:name', (req, res) => {res.send('hello world');});startServer(app);
persistenceOptions
is used for customizing the persistence method. Currently supports leveldb and mongodb.startServer({persistenceOptions: {provider: 'leveldb',dir: './db',},});
startServer({persistenceOptions: {provider: 'mongodb',url: 'mongodb://localhost:27017',},});
contentField
is used to customize the field name of the document content. The default is content
.startServer({contentField: 'content',});
callback
is used for customizing the update callback.startServer({callback: {// Or use `action: string` to receive post requests via a urlaction: (data: Record<string, any>) => {// `data` is the updated data},// Timeout duration, defaulting to 5000mstimeout: 5000;// ContentType can be "Array" | "Map" | "Text" | "XmlFragment"// Corresponding data types to be sentobjects?: Record<string, ContentType>;},});
startServer
returns an http.Server
instance, and you can get the corresponding Y.Doc
instance via server.getYDoc(name)
.const server = startServer();const doc = server.getYDoc('docname');