yjs / y-prosemirror

ProseMirror editor binding for Yjs

Home Page:https://demos.yjs.dev/prosemirror/prosemirror.html

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Remove the dependency ySyncPlugin has on prosemirror-view

stuburger opened this issue · comments

Hi there!

I have a use case for collaboratively editing a prosemirror document on a nodejs server. Essentially the code running on the server acts in the same capacity as a client (like a user in the browser) making programatic edits to a prosemirror document. Since the ySyncPlugin exported by y-prosemirror needs to bind to the EditorView instance to dispatch transactions and read editor state, it means I have to include the prosemirror-view module and mock out the dom using jsdom. While this works perfectly well, it would be nice to not have to include the dependencies on prosemirror-view and jsdom.

y-prosemirror actually does seem to have some check in place to see if environment.isBrowser, but prosemirror-view does not and still wants to create dom nodes.

Here are the 2 approaches I've taken to get this working. Both work, but both have drawbacks...

First approach:

import { JSDOM } from 'jsdom'
import { ySyncPlugin } from 'y-prosemirror'

const dom = new JSDOM(`<!DOCTYPE html><body></body>`)

// make sure these are available otherwise we error out when newing up an EditorView
global.window = dom.window
global.document = dom.window.document

class Editor {
  constructor(config) {
    const type = config.ydoc.getXmlFragment('prosemirror')
    this.editorState = EditorState.create({ schema, plugins: [ySyncPlugin(type)] })
    // I havent dug into the code, but I was hoping at least that not supplying a "place"
    // to the EditorView would indicate that I may not be in a browser
    this.view = new EditorView(undefined, { 
      state: this.editorState, 
      dispatchTransaction: (tr) => this.apply(tr)
    })
  }

  apply(tr) {
    this.editorState = this.editorState.apply(tr)
    this.view.updateState(this.editorState)
  }
}

Second approach:

The other approach I've tried which seems to work looks something like this:

import { ProsemirrorBinding, ySyncPlugin } from 'y-prosemirror'

class Editor {
  constructor(config) {
    const type = config.ydoc.getXmlFragment('prosemirror')

    // `this` implements the parts of EditorView that the ProsemirrorBinding class cares about
    // i.e. state, dispatch() and hasFocus()
    this.binding = new ProsemirrorBinding(type, this)

    const ysync = ySyncPlugin(type)

    // patch ySyncPlugin's init function so that it gets its binding up-front
    // since prosemirror-view is absent and ySyncPlugin.view() is never called
    const { init } = ysync.spec.state
    ysync.spec.state.init = (initargs, state) => ({
      ...init.call(ysync, initargs, state),
      binding: this.binding,
    })

    this.editorState = EditorState.create({ schema, plugins: [ysync] })
  }

  get state() {
    return this.editorState
  }

  dispatch(tr) {
    this.apply(tr)
  }

  hasFocus() {
    return false
  }

  apply(tr) {
    this.editorState = this.editorState.apply(tr)
    this.binding._prosemirrorChanged(this.editorState.doc)
  }
}

This approach would force me to take a lot more care when upgrading to future versions of y-prosemirror as implementation details may change. So both approaches have drawbacks.

It feels like the implicit coupling of the ySyncPlugin to the EditorView ideally should be avoided if possible (after all, why should state and state updates be concerned with rendering? 🤔)

Proposed solution

Unfortunately I don't have much of a solution to propose - I'm new to yjs and even to prosemirror, really.. But it might be good to extend the ySyncPlugin api to allow some extra config that gives one more control over how transactions are dispatched and applied. Similar to what I've done in approach 2.

There really does seem to be a bit of an impedance mismatch between yjs and the prosemirror plugin system that introduces some awkwardness in getting the 2 to place nice. Is that a fair assessment? Perhaps there is an argument to be made to implement some of the YJS "stuff" outside of the plugin system...?

I know this way of using your plugin is extremely rare and I'm fully prepared for a "wontfix", I but thought I'd put it out there and let you know that such a use case exists 😀 Whatever the case, I'm curious to hear options on all of the above. Thanks for all the open source goodness!

The y-sync plugin must interact with the view for various reasons. One of them is that they need to listen to change events emitted by the view. There is literally no other way to listen to changes. Even the collab extension by the author of ProseMirror depends on the view. So I don't think there is anything I can do here.

But two other approaches:

If you just want to manipulate the state, you can use Yjs instead. It provides a different API, but the same functionality.

If you just want to generate the ProseMirror state based on the Yjs document, you can use the utility functions that don't have any dependencies on the view.

I'm closing this issue, but feel free to ask questions.

@dmonad it's possible to use prosemirror without prosemirror-view (https://discuss.prosemirror.net/t/is-there-a-way-to-run-prosemirror-on-nodejs/2517).

In my case I need to create marks across document, please is there any workaround for how to do it with only yjs API? I have to admit that it is quite difficult to use only yjs without prosemirror :).

Edit: I posted question to discussion https://discuss.yjs.dev/t/update-prosemirror-state-using-y-prosemirror-in-nodejs/1940