Skip to content
This repository has been archived by the owner on Feb 6, 2023. It is now read-only.

Collaborative Editing #93

Open
simonlast opened this issue Feb 25, 2016 · 67 comments
Open

Collaborative Editing #93

simonlast opened this issue Feb 25, 2016 · 67 comments

Comments

@simonlast
Copy link

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.

@knpwrs
Copy link

knpwrs commented Feb 26, 2016

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).

@hellendag
Copy link

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, Modifier methods serve to wrap multiple transactions -- we would need to record each transaction in a list to make the appropriate change fully available.

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.

@sophiebits
Copy link
Contributor

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

writing collabor[-itiv-][+ative+]ly using draft.js

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.

@hellendag
Copy link

@spicyj: Yeah, I think that can be done in the higher-level component via ContentState diffing, though I haven't tried it. I mentioned spellcheck as an example of where we would have to add something new to perform diffing internally within the handler, since we don't currently need it. I veto adding complexity to the event handlers for this. :)

(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?

@sophiebits
Copy link
Contributor

MO worked okay for the most part, but was a huge headache for handling Apple autocorrect.

Why was that?

@sophiebits
Copy link
Contributor

I just typed

hello, here is a banana

in Google Docs then backspaced "banana" and changed it to

hello, here is an orange

in another window. Then undo in the first window gave:

helln orange
n orange

then undo in the second window gave

        <-- (nothing)
banana

So it seems like each window has its own relatively-independent undo stack that reverses each "editing transaction".

@ivzhao
Copy link

ivzhao commented Feb 26, 2016

"...but was a huge headache for handling Apple autocorrect"

What was that specifically? @hellendag

@rgbkrk
Copy link
Contributor

rgbkrk commented Feb 26, 2016

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.

@hellendag
Copy link

What was that specifically? @hellendag

Check out https://jsfiddle.net/salier/WWagu/3/ to play around with it.

The characterData mutations for a regular spellcheck look something like this:

'testt' -> 'test'
'' -> 'test'

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:

'testt' -> 'test '
'' -> 'test '
'test' -> 'test '

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 input (which we do now), and try to figure things out from the state of the world.

@ianstormtaylor
Copy link
Contributor

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.

@adrianmcli
Copy link

@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

@slorber
Copy link

slorber commented Jun 7, 2016

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.

@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.

@disordinary
Copy link

@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.

@disordinary
Copy link

@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.

@gaborsar
Copy link

gaborsar commented Jul 6, 2016

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?

@disordinary
Copy link

@gaborsar you should look for another solution, prose mirror and ritzy both support collaborative editing.

@gaborsar
Copy link

gaborsar commented Jul 7, 2016

@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!

@disordinary
Copy link

disordinary commented Jul 7, 2016

@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.

@gaborsar
Copy link

gaborsar commented Jul 7, 2016

@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

@gaborsar
Copy link

@hellendag I have one / two questions about the suggestions you had before:

  1. Can a block be disabled currently?
  2. Is there a way to know what blocks actually changed after a change event? This is especially important, as scanning and diffing a long document after every change event (including cursor position changes) would be very expensive.

@rainhead
Copy link

The Operational Transformation Wikipedia page lists some papers about implementing undo in OT. I haven't checked them out yet.

@varunarora
Copy link

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.

@gaborsar
Copy link

gaborsar commented Sep 19, 2016

@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.

@varunarora
Copy link

@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.

@knpwrs
Copy link

knpwrs commented Sep 19, 2016

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.

@n-scope
Copy link

n-scope commented Sep 27, 2016

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...

@knpwrs
Copy link

knpwrs commented Sep 27, 2016

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.

@gaborsar
Copy link

@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.

@jclem
Copy link

jclem commented Feb 28, 2017

@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.

@gaborsar
Copy link

@jcelm

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.

@ianstormtaylor
Copy link
Contributor

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

@eliwinkelman
Copy link

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.

@ArnaudRinquin
Copy link

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 EditorStates. Why can't DraftJS handle a onDiff prop, similar to onChange but that would expose these diffs (and probably the prev/next state along them). This would allow the diff to be converted to whatever OT/CRDT format you use.

Am I stupid or as this already been proposed somewhere?

@gaborsar
Copy link

gaborsar commented Mar 1, 2017

@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
            }
        }
    ]
}

@gaborsar
Copy link

gaborsar commented Mar 1, 2017

@ArnaudRinquin
Quote from @hellendag #93 (comment)

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.

@ArnaudRinquin
Copy link

ArnaudRinquin commented Mar 1, 2017

@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

@gaborsar
Copy link

gaborsar commented Mar 1, 2017

@ArnaudRinquin Commented the same time, see above ^^ :)

@ArnaudRinquin
Copy link

ArnaudRinquin commented Mar 1, 2017

@gaborsar I am not sure I understand @hellendag's quote perfectly. Especially this sentence:

Since we're using immutable states, the actual delta in the text is unimportant for our state management, and is not tracked.

Would it be possible to adjust and make these concerns important and tracked so we can expose operations? Or is it just impossible ?

@gaborsar
Copy link

gaborsar commented Mar 1, 2017

@ArnaudRinquin
Other quote from the same comment:

Additionally, Modifier methods serve to wrap multiple transactions -- we would need to record each transaction in a list to make the appropriate change fully available.

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.
Of course, as I am not a collaborator of Draft I can be wrong, so if a Draft collaborator would comment on this - how much work would it be to add deltas/diffs, that would be very helpful! :)

@thom4parisot
Copy link

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 :-)

@TimYi
Copy link

TimYi commented Mar 28, 2017

@jclem In our spreadsheet collaborative editing system, we use a improved woot based ot algorithm. Document size is linear to size of current document.
The basic idea is only store alive character and their address in a text document(same to row or column in a table). Address is character's index in a increase only address space.

For example, a string '01234', we may use this model to record it:
{content:'01234', addresses:[0,1,2,3,4]},
if we insert 'a' to index 2, the operation will be:
{type:'i', char:'a', index:2, address:2}
the document becomes:
{content:'01a234', addresses:[0,1,2,3,4,5]}
then we delete character 2, the operation will be:
{type:'d', char:'2', index:3, address:3}
and the document becomes:
{content:'01a34', addresses:[0,1,2,4,5]}

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.
Every insert, delete, move operation should have both index and address, ot should be base on address, meanwhile modify it's index properly.
And inverse property 2(IP2) can be satisfied, so undo is easy to implement. IP3 can not be satisfied, so all the ot algorithm should obey last write win policy, so that undo seems right and duplicate undo or redo will only take effect once.

This approach assumes a server-client structure, and will not support p2p applications.

@threescales
Copy link

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.

@gaborsar
Copy link

@threescales Depends on what do you mean by collaboration. This would be too slow for real-time collaboration.

@threescales
Copy link

@gaborsar What about only one block in each draft editor?can i covert it to ot?

@gaborsar
Copy link

gaborsar commented Jul 15, 2017

@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.

@si13b
Copy link

si13b commented Aug 11, 2017

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?

@ArnaudRinquin
Copy link

@si13b That's basically what we do. It works.

@thesynthetic
Copy link

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?

//From draft-js/src/component/handlers/edit/editOnKeyDown.js

function onKeyCommand(
  command: DraftEditorCommand | string,
  editorState: EditorState,
): EditorState {
  switch (command) {
    case 'redo':
      return EditorState.redo(editorState);
    case 'delete':
      return keyCommandPlainDelete(editorState);
    case 'delete-word':
      return keyCommandDeleteWord(editorState);
    case 'backspace':
      return keyCommandPlainBackspace(editorState);
    case 'backspace-word':
      return keyCommandBackspaceWord(editorState);
    case 'backspace-to-start-of-line':
      return keyCommandBackspaceToStartOfLine(editorState);
    case 'split-block':
      return keyCommandInsertNewline(editorState);
    case 'transpose-characters':
      return keyCommandTransposeCharacters(editorState);
    case 'move-selection-to-start-of-block':
      return keyCommandMoveSelectionToStartOfBlock(editorState);
    case 'move-selection-to-end-of-block':
      return keyCommandMoveSelectionToEndOfBlock(editorState);
    case 'secondary-cut':
      return SecondaryClipboard.cut(editorState);
    case 'secondary-paste':
      return SecondaryClipboard.paste(editorState);
    default:
      return editorState;
  }
}

@calibr
Copy link

calibr commented Apr 23, 2018

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.

@droegier
Copy link

droegier commented Dec 9, 2018

So, I implemented realtime collaboration using ShareDB over the summer. Here's a description of the setup if anyone is interested.

@archonic
Copy link

archonic commented Dec 9, 2018

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.

@UdaraJay
Copy link

UdaraJay commented Feb 15, 2019

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)

@jarl-alejandro
Copy link

@simonlast How did you resolve the collaboration on Notion.so?

@ajay01994
Copy link

Has this - (https://github.com/yjs/yjs) been implemented by someone for collaborative writing

@mateuspiresl
Copy link

mateuspiresl commented May 2, 2022

So, I implemented realtime collaboration using ShareDB over the summer. Here's a description of the setup if anyone is interested.

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:

As a temporary solution we placed a lock on the entire page, which can be requested and passed from one user to the other.

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.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests