-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Collaborative Editing #93
Comments
This is something Prosemirror has gotten working, and they have a similar method of handling documents from what I can immediately tell (immutable data structure decoupled from the DOM). |
Thanks for starting the conversation on this. Collaborative editing is something I've thought about quite a bit. Since we haven't had a need for it at Facebook yet, I haven't tried building anything to make it happen. I've considered your suggestion of exposing the operations themselves, but part of the issue there is that we don't necessarily have information about the operations. For instance, with spellcheck handling, we throw out the old value for the selected text node and replace it with the new value. Since we're using immutable states, the actual delta in the text is unimportant for our state management, and is not tracked. Additionally, One option I have considered is the Quip approach of locking blocks that are being edited by others. In this way, as the remote user modifies the locked block, the full state of that block can be sent at intervals. (Live per-character changes aren't especially important if the block is locked.) It's a heavier payload to send a whole block, of course. I think it would also be possible to identify deltas between ContentStates (or individual ContentBlocks) for transmission, after transactions have already been performed. |
It seems like for the spellcheck case we could do a "diff" of the old and new strings within a block to infer the change – even just stripping off a common prefix and suffix, so -writing collaboritivly using draft.js
+writing collaboratively using draft.js would turn into essentially
which we could more easily represent as a state "transaction". I personally get frustrated by Quip's "block locking" and much prefer how Google Docs lets two people edit one paragraph. |
@spicyj: Yeah, I think that can be done in the higher-level component via (Historical note: affix checking was how we originally handled spellcheck, via a MutationObserver within the core component. MO worked okay for the most part, but was a huge headache for handling Apple autocorrect.) One thing I wonder about but haven't investigated in Google Docs etc. is how undo/redo works during collaborative editing. If I make a bunch of changes that someone else then modifies, what happens if I try to step backward? |
Why was that? |
I just typed
in Google Docs then backspaced "banana" and changed it to
in another window. Then undo in the first window gave:
then undo in the second window gave
So it seems like each window has its own relatively-independent undo stack that reverses each "editing transaction". |
What was that specifically? @hellendag |
After watching your talk @hellendag, it seemed like the way the cursor is tracked is mapping fairly cleanly to a good model for doing operational transform, differential sync, or google's diff-match-patch algorithm. I'm going to be experimenting this weekend with it. |
Check out https://jsfiddle.net/salier/WWagu/3/ to play around with it. The characterData mutations for a regular spellcheck look something like this:
We could use the old value for the first mutation as the basis for the affix logic to figure out the diff. For autocorrect occurring when inserting a space after a misspelling, it looks more like this:
These are kind of trivial changes, and IIRC it got messier with multi-word autocorrects. So we actually used guesswork to handle it within our diffing logic: assume 2 records for spellcheck, 3 for autocorrect, then try to evaluate the appropriate outcome from there. Since records are batched differently for spellcheck and autocorrect, we also wrapped everything in a requestAnimationFrame to ensure that related records would be batched together. This didn't always work. The browser provides no events to indicate that spellcheck/autocorrect will occur or has occurred. All you can really do is put up with MO or listen for |
Just wanted to say +1 to this. I think it's one of the most important pieces to unlock in core to make Draft.js a complete (and amazing) solution for building editors. Since the current core is so flexible, most other features can be created entirely in "userland", but this one would need a bit more work in core to make happen. And it would end up being amazing. |
@rgbkrk Have you come to a conclusion on which of the algos you've mentioned might be most suitable? I'm just getting started researching this and I'm about to read the pages you've linked to. Personally, the first thing that came to mind was CRDT which was implemented in this project: https://github.com/ritzyed/ritzy He goes into a decent amount of detail regarding his implementation here: https://github.com/ritzyed/ritzy/blob/master/docs/DESIGN.adoc |
@hellendag Hi. I don't know so much DraftJS so far but I'm interested to know. Do you think this approach can be implemented in userland? For example can it be possible to add a class to locked blocks, and refuse new inputs only on these blocks? I'm interested by this because I need the annotate/highlight like Medium does. They mark every top-level text node (p, h1, h2, pre, code...) with a name, and their annotations are positionned with (nodeName,offset). This way it's easy for them to keep sanity in their annotations even after text edition (while plugins like AnnotatorJS, based on XPath strings, tend to fail hard after edits). So I'll probably already have named blocks, and I'm ok to not have the shiniest CRDT algorithm and locking text nodes is fine for me. |
@adrianmc a CRDT is an easier option to implement than an OT based solution, however it might not be suited with the diff->patch->match approach unless the diffs are very aggressive. The problem being that the CRDT keeps all changes in it including removed characters and so can quickly grow even if you're only doing single character changes. One solution is to change the way a diff occurs, we know that text in an editor happens in a contiguous block, so we can diff based on characters and compare what's in the underlying model with what is physically in the dom. A simple approach is to step forwards from the start of the two strings and step backwards from the end until the first changes are found on each side and we can isolate the change block which is a bit more efficient. |
@hellendag I'm guessing that google docs uses the command pattern rather than storing the immutable state, this approach would make the most sense for a CRDT where you don't want to delete a character if you undo creating it but simply tombstone it. In an OT based solution you could store and replay the operations that have occurred since the object was in the state that is triggered by the undo. |
I am currently working on a project where document editing is an important part and collaborative editing is a requirement. Do anybody here have experience here whether it can be implemented with draft-js or I should look for another solution? |
@gaborsar you should look for another solution, prose mirror and ritzy both support collaborative editing. |
@disordinary Thank you for the reply! I was afraid of that. Draft is nice, I have already integrated it in the first prototype. The option to have custom block components would be quite important to me, but without a delta based saving I have to rethink my decision... Thank you for the advice as well! |
@gaborsar The thing is it's not a great need for most people, so i've found that if you want it done you just kind of have to do it. Ritzy is built on a particularly inefficient CRDT and so i'd assess it carefully for your use case, it will be fine for small datasets and short editing sessions but you wouldn't want to write a novel on it. ProseMirror might not be as flexible as you want. I'm experimenting with mobiledoc-kit to see how I can build collaboration on that platform, but it really is extremely early. |
@disordinary I have to plan with approximately 100+ pages long text elements. I think I have to read Medium's article very well then... :D |
@hellendag I have one / two questions about the suggestions you had before:
|
The Operational Transformation Wikipedia page lists some papers about implementing undo in OT. I haven't checked them out yet. |
So I am wondering why no one here is talking about Google's Realtime API (https://developers.google.com/google-apps/realtime/overview#watch_the_video_overview). Their way of thinking about mutations is surprisingly similar to @hellendag's talk. It's also interesting that they solve the same network layer problem that GraphQL does. So, at the end of the day, I am super interested in continuing to do mutations through GraphQL (w/ server AND p2p?), but keep Draft's power. Else I'd need to stitch Draft AND Google Realtime API, and keep mutating the server through timeouts and GraphQL too - but turn off the subscription to update state from the GraphQL's server connection when collaborating. Not neat! Sorry I know this doesn't add new ideas, but just thought I'd share what's going on in my head since I have been following this post. |
@varunarora For a lot of using a google service is not an option to store the data. Also, there are a few other OT implementations that you can use (Apache Wave, ShareJS - implemented by an ex-Google-Wave guy). On the other hand, OT is not the only model to do collaboration, block level locking on selection changes would be a good starting point too (like Quip does it). The responsibility of Draft here would be to provide an API to catch every mutation and allow us to implement any kind of collaboration. Of course, this is just my opinion. |
@gaborsar Oh absolutely. I don't WANT to use a Google service here. So, thanks so much for telling me about these other OT implementations! Much appreciated. I will try them out, because I think my users won't like block level locking too much. Yeah I don't know about the scope of Draft's existence, but I actually think because so many people care for this use case, it should be a collaborative add-on/mixin that someone builds exclusively for the Draft world. Like react-router for react. |
The ProseMirror solution is almost like OT but operations have to be applied in order. It works more like a rebase. If I understand it correctly the client attempts to send its changes to a central server. If the changes fail then the client gets the latest changes, rebases its changes on top of them, and tries again. All the while it is receiving a stream of successful changes and rebasing buffered changes on top of those. The client only attempts to send one change at a time, buffering multiple changes into one change if necessary. This is possible to implement with a transactional database that checks the version of the document it is applying incoming changes to. The problem I see here is that it would be potentially harder to scale than OT, which is theoretically easier to horizontally scale. |
I am interested in collaborative editing as well. Prosemirror is great, but in the near future CKEditor 5 (or alloyeditor 2 which would be based on it) seem to be good contenders. However is collaborative editing really not an option with draft js ? Is @gaborsar 's approach of scanning + diffing draft state really that bad, performance wise ? I would have thought the immutable state of draft.js would help to determine quickly which parts have changed. Then we need a way to implement block level locking... |
Just my two cents -- I am 100% against block-level locking, however much simpler it may be. Google Docs set a precedent of how collaborative editing should work and anything less than that feels janky and half-baked. |
@jclem We had a very similar journey! :) We tested woot and logoot on a prototype level, but woot is generally slow (linear) and logoot keys on character can level grow way too fast. At the end we did land at ShareDB too, and implemented our own editor (React), and we did borrow a few ideas from quill for it. My best advice for anyone here: if having react components in the editor (custom blocks, wrappers, etc...) is a not a must have, than quill is a good editor and support collaboration with ShareDB out of the box. I do not think that draft can be forced to do comparable real time collaboration, even if I still think it is a nice project. |
@gaborsar Out of curiosity, what keeps Draft from doing that kind of OT? Edit: Ah, re-reading the thread it looks like it's because a full document diff would be necessary for each operation because of how Draft's events are done. That's too bad 😦 If that's the case, it seems like neither OT nor any CmRDT are options. CvRDTs (state-based) might work, but that's very inefficient unless you implement a delta CvRDT, in which case you may have the same problem. |
Draft has a document model that is pretty far from the existing rich-text OT implementation of ShareDB. Quill on the other hand has been designed together with that. The rich-text type of ShareDB has been created by the quill team. In my opinion it would require a lot of work to upgrade Draft to support ShareDB the same way. It would either mean a new OT type designed for Draft, or replacing of the model. In either case, the undo / redo, the modifier utils, the rich text utils, the selection handling and lot of other thing should be pretty much rewritten. |
For what it's worth, since I've been following this conversation since I first started experimenting with Draft. I've actually been building a Draft-like library for React editors that is less opinionated, and architected in a way that is more OT-friendly (all transforms as operations, undo/redo replays the operations, etc.). There is still some more work that needs to happen to have OT be super easy to add straight from core, but it's close enough that if anyone was interested in contributing to help get it there it wouldn't be an near-unsurmountable problem, like trying to convert Draft to OT might be. If anyone is interested, it's called Slate — https://github.com/ianstormtaylor/slate |
I hadn't realized that Sharedb and quill js worked together, I looked at sharejs + quilljs but quill js changed their api going into 1.0 and no longer supported the json otype, I didn't see the new Rich-Text type. I think I'll be switching to that. @ianstormtaylor good luck with slate. |
I might have missed something in the discussion but can someone explain why the following isn't possible: I suppose DraftJS already makes state diffs between previous and next Am I stupid or as this already been proposed somewhere? |
@ArnaudRinquin There are no diffs in Draft. Draft has modifier functions that are responsible to create new versions of the state (Modifier and RichUtils modules). Making the selected text bold in Draft: const editorState = RichUtils.toggleInlineStyle(this.state.editorState, 'BOLD');
this.setState({ editorState }); Making the selected text bold in Quill: const delta = quill.format('bold', true); Generated ShareDB / rich-text friendly delta assuming that the characters 10..15 are selected: {
"ops": [
{
"retain": 10
},
{
"retain": 5,
"attributes": {
"bold": true
}
}
]
} |
@ArnaudRinquin
|
@gaborsar Oh I know how to apply changes to the EditorState but I assumed that, at some point, DraftJS would diff the prev/next EditorStates to detect and apply the changes (which would go against React way of doing things, I agree). Can you confirm that there is no EditorState diff made by DraftJS, and that applying changes only rely on React ? Edit: just saw your quote from @hellendag |
@ArnaudRinquin Commented the same time, see above ^^ :) |
@gaborsar I am not sure I understand @hellendag's quote perfectly. Especially this sentence:
Would it be possible to adjust and make these concerns important and tracked so we can expose operations? Or is it just impossible ? |
@ArnaudRinquin
Everything is possible, but this is why I wrote before that preparing Draft for realtime collaboration would require pretty much a rewrite of the Modifier and RichUtils modules. |
In any case, I'd be happy to try out this feature if it happens—I fancy some git backend to write out asciidoc documents with Draft :-) |
@jclem In our spreadsheet collaborative editing system, we use a improved woot based ot algorithm. Document size is linear to size of current document. For example, a string '01234', we may use this model to record it: We see that in address space, character '2' and it's address 3 is not deleted, but in current document data, we only store alive character and their address, the deleted character and their address is only in our mind. This approach assumes a server-client structure, and will not support p2p applications. |
Do not know draft-js Now there is no support for collaborative editing, but I checked a lot have not seen, now think of a way I do not know whether feasible, record old contentState and new contentState, and then through the difference algorithm to calculate the difference between the two json string , Convert it to ot. I wonder if this idea is feasible. |
@threescales Depends on what do you mean by collaboration. This would be too slow for real-time collaboration. |
@gaborsar What about only one block in each draft editor?can i covert it to ot? |
@threescales If I understand what you say correctly, that would mean the entire document would be re-rendered after every key stroke. In my opinion, there is no current best solution to this problem with draft, but of course, you can find different workarounds, and they may work very well up to a limited document size. |
Forgive my ignorance on this topic, but isn't React itself a very efficient diffing and patching tool? Could you not do something similar to React whereby state changes in Draft are diffed at the highest-level (blocks) very efficiently using reference comparisons. You could then send the updated block "patch" over the wire to other clients. If doing this every keystroke is inefficient then you could throttle or debounce it? |
@si13b That's basically what we do. It works. |
I'm seeing some operation-like functions within the Component Handlers. Wonder if these can be used to create operations that can be used for Operational Transformation? If all commands are captured by DraftJS and then actions are performed by DraftJS, it would seem theoretically possible. No?
|
Looks like guys from slite are using Draft and passing data to/from the server using quill-like deltas. Maybe I am mistaken, but structure of their editor looks very close to draft's one. |
So, I implemented realtime collaboration using ShareDB over the summer. Here's a description of the setup if anyone is interested. |
Thanks for the article @droegier! My company is currently paying a disturbing amount for CKEditor5 and it's collaborative editor cloud solution. If anyone figures out a rock-solid collaborative solution, know that it will be in high demand. |
What's stopping anyone from leveraging a shared undo/redo stack as a record of operations? Has anyone tried that? (on a separate note - the SharedDB solution seems to get the job done) |
@simonlast How did you resolve the collaboration on Notion.so? |
Has this - (https://github.com/yjs/yjs) been implemented by someone for collaborative writing |
Thanks, @droegier! Your article gave me some hope on implementing collab over Draft.js 🙏🏼 but I'm a little confused with the conclusion there. In my use case, the application editor doesn't have multiple pages. The user works with texts with the size of lyrics, so your conclusion, if I got that right, is a concern and the architecture you proposed doesn't apply to my use case. The part I'm referring to:
I'm ok with block locking because a block has only one line and verse in the lyrics world, but page locking means the entire lyrics. Am I understanding the conclusion correctly? I have left a comment in your article but I'm bringing the question here too so that it can help anyone else with a similar problem. Thanks again for sharing that article. |
It would be great if this library exposed abstractions required for collaborative editing.
One way to do this would be to expose an event handler that, instead of emitting the entire new state, emitted a transaction object that described how the state changed. An implementer would then need to have a collaborative data model, which would then ingest the transaction objects.
Another way would be for Draft.js to represent its state using a collaborative data model (CRDT for example), and be able to both emit and ingest transactions. This would obviously be a lot harder.
The text was updated successfully, but these errors were encountered: