UNPKG

@milkdown/plugin-collab

Version:

Collaborative editing support for [milkdown](https://milkdown.dev/).

178 lines (177 loc) 5.35 kB
import { schemaCtx, parserCtx, getDoc, prosePluginsCtx, editorViewCtx, EditorViewReady } from "@milkdown/core"; import { createSlice, createTimer } from "@milkdown/ctx"; import { ctxNotBind, missingYjsDoc } from "@milkdown/exception"; import { keydownHandler } from "@milkdown/prose/keymap"; import { Plugin, PluginKey } from "@milkdown/prose/state"; import { ySyncPlugin, yUndoPlugin, redo, undo, yCursorPlugin, yXmlFragmentToProseMirrorRootNode, prosemirrorToYDoc, ySyncPluginKey, yCursorPluginKey, yUndoPluginKey } from "y-prosemirror"; import { encodeStateAsUpdate, applyUpdate } from "yjs"; const CollabKeymapPluginKey = new PluginKey("MILKDOWN_COLLAB_KEYMAP"); const collabPluginKeys = [ CollabKeymapPluginKey, ySyncPluginKey, yCursorPluginKey, yUndoPluginKey ]; class CollabService { /// @internal #options = {}; /// @internal #xmlFragment = null; /// @internal #awareness = null; /// @internal #ctx = null; /// @internal #connected = false; /// @internal #valueToNode(value) { if (!this.#ctx) throw ctxNotBind(); const schema = this.#ctx.get(schemaCtx); const parser = this.#ctx.get(parserCtx); const doc = getDoc(value, parser, schema); return doc; } /// @internal #createPlugins() { if (!this.#xmlFragment) throw missingYjsDoc(); const { ySyncOpts, yUndoOpts } = this.#options; const plugins = [ ySyncPlugin(this.#xmlFragment, ySyncOpts), yUndoPlugin(yUndoOpts), new Plugin({ key: CollabKeymapPluginKey, props: { handleKeyDown: keydownHandler({ "Mod-z": undo, "Mod-y": redo, "Mod-Shift-z": redo }) } }) ]; if (this.#awareness) { const { yCursorOpts, yCursorStateField } = this.#options; plugins.push( yCursorPlugin( this.#awareness, yCursorOpts, yCursorStateField ) ); } return plugins; } /// @internal #flushEditor(plugins) { if (!this.#ctx) throw ctxNotBind(); this.#ctx.set(prosePluginsCtx, plugins); const view = this.#ctx.get(editorViewCtx); const newState = view.state.reconfigure({ plugins }); view.updateState(newState); } /// Bind the context to the service. bindCtx(ctx) { this.#ctx = ctx; return this; } /// Bind the document to the service. bindDoc(doc) { this.#xmlFragment = doc.getXmlFragment("prosemirror"); return this; } /// Bind the Yjs XmlFragment to the service. bindXmlFragment(xmlFragment) { this.#xmlFragment = xmlFragment; return this; } /// Set the options of the service. setOptions(options) { this.#options = options; return this; } /// Merge some options to the service. /// The options will be merged to the existing options. /// THe options should be partial of the `CollabServiceOptions`. mergeOptions(options) { Object.assign(this.#options, options); return this; } /// Set the awareness of the service. setAwareness(awareness) { this.#awareness = awareness; return this; } /// Apply the template to the document. applyTemplate(template, condition) { if (!this.#ctx) throw ctxNotBind(); if (!this.#xmlFragment) throw missingYjsDoc(); const conditionFn = condition || ((yDocNode2) => yDocNode2.textContent.length === 0); const node = this.#valueToNode(template); const schema = this.#ctx.get(schemaCtx); const yDocNode = yXmlFragmentToProseMirrorRootNode( this.#xmlFragment, schema ); if (node && conditionFn(yDocNode, node)) { const fragment = this.#xmlFragment; fragment.delete(0, fragment.length); const templateDoc = prosemirrorToYDoc(node); const template2 = encodeStateAsUpdate(templateDoc); if (fragment.doc) applyUpdate(fragment.doc, template2); templateDoc.destroy(); } return this; } /// Connect the service. connect() { if (!this.#ctx) throw ctxNotBind(); if (this.#connected) return; const prosePlugins = this.#ctx.get(prosePluginsCtx); const collabPlugins = this.#createPlugins(); const plugins = prosePlugins.concat(collabPlugins); this.#flushEditor(plugins); this.#connected = true; return this; } /// Disconnect the service. disconnect() { if (!this.#ctx) throw ctxNotBind(); if (!this.#connected) return this; const prosePlugins = this.#ctx.get(prosePluginsCtx); const plugins = prosePlugins.filter( (plugin) => !plugin.spec.key || !collabPluginKeys.includes(plugin.spec.key) ); this.#flushEditor(plugins); this.#connected = false; return this; } } const collabServiceCtx = createSlice( new CollabService(), "collabServiceCtx" ); const CollabReady = createTimer("CollabReady"); const collab = (ctx) => { const collabService = new CollabService(); ctx.inject(collabServiceCtx, collabService).record(CollabReady); return async () => { await ctx.wait(EditorViewReady); collabService.bindCtx(ctx); ctx.done(CollabReady); return () => { ctx.remove(collabServiceCtx).clearTimer(CollabReady); }; }; }; collab.meta = { package: "@milkdown/plugin-collab", displayName: "Collab" }; export { CollabKeymapPluginKey, CollabReady, CollabService, collab, collabServiceCtx }; //# sourceMappingURL=index.js.map