Build or buy?
Build or buy, a friendly heads-up of what you’re up against.
Rich text editors (not to even mention word processors) are notoriously difficult to build on the web, and their simple mention would send chills down the spine of any dev brave enough to have ever dipped their toes in the wonderful world of WYSIWYGs. Thankfully, we have wonderful libraries like ProseMirror and react-prosemirror that handle a lot of the complexity for us (cursor, touch support, multi-browser support, selection quirks, etc.). The foundations are out there for anyone to attempt building this, but let me give you a quick overview of some of the challenges you’ll face if you want to create a real Word-like editing experience.
1. Everything depends on everything else.
You have no idea the number of times we’ve had to go back to the drawing board and rewrite features because the next one broke it. A text processor isn’t something you just build bit by bit, it’s a complex system that relies on a lot of other systems to work together. The best example we have for this is track changes. You built search? Cool, you can rewrite it to take into account the deletions; you wouldn’t want part of a deleted word to come up in a search result, would you? Proofing? Same. You’re happy with your formatting CSS tricks? Well, guess again, here comes pagination and stylesheets. We want to manage your expectations here, if you build this, do not expect to smoothly roll out a product incrementally, unless you’re really into migrating data all the time and live on the edge.
2. Formatting paragraphs is not as simple as setting a few css classes.
Left and right are simple enough. Then you have hanging and first-line indents. Want to up the challenge? Add numbering, followed by a tab, a space? Nothing at all? You got it? Cool, are you sure your cursor doesn’t get stuck in it with arrow navigation? Yeah… that’s no fun. Alright, now that you’ve got your formatting figured out, move on to top and bottom spacing. No big deal, right? A-la-Word, margins collapse, CSS supports it, done deal. Well, not so fast, buckaroo. CSS margin collapse is probably one of the most unpredictable features in CSS, and it’s going to fight you every step of the way. Want to add borders to your paragraphs? Neat, now how about 5 paragraphs with the same formatting and the same border. Keep margin collapse and… why are there two borders between each paragraph? And why is there white space between them if I set their background blue? And did the spacing just change as I applied borders? You get the idea. Not to mention pagination… Ever tried measuring collapsed margins efficiently? If so please share your wisdom with us.
3. I was wondering why legal numbering wasn’t more popular on web editors.
Especially since so many of them pride themselves on having ‘Legal documents’ as use cases in their marketing. We’ve worked in law firms; we know what lawyers want and how a misaligned pixel can ruin their day. You want serious business? Well, I don’t see this happening if your lawyers can’t set the right number on the clause they want. And they sure as hell don’t have the time or patience to work around your product. So let’s implement it! Simple enough, right? Bullets, roman, arabic, so far so good. Ok, but now this specific contract requires a 1.1.a).Clause 1 and then square bullet points. Uhhh? And lists can be intertwined with other lists, and sometimes need to restart at 1 at some points and at any level, and the users want ordinals in Spanish and French too? And then there is the UX: what happens on enter or backspace on the first level, what about the other levels, when the item is indented, when the item is split, and what if the user suddenly decides a paragraph or heading in the middle of the doc is numbered? Do you start at 1 every time? What if the item above is numbered? They would expect the list to continue, right? What if we suddenly decide this level for this list should be a bullet and not a number? We now have all those other numbered items to update, and of course, that means a new list config! Who knows, maybe another list you used somewhere else uses the same list config. You wouldn’t want to overwrite that one too, right?
4. Pagination is a nightmare.
And I wrote a whole article about it. So I won’t go into any more detail. Read more about it here.
5. Chrome has proofreading, easy right?
Yes it does. But you can’t use it. Track changes with deleted nodes? It will flag the whole edited word as wrong. Right off the bat, you’re done. Multiple languages in the same document? Can’t do it. Footnote anchor after a word? Guess what, squiggly underlines for you. Want to check the entire document for errors? AND conveniently list them for the user? Good luck with that. And it’s not even catching grammar mistakes properly. So you can’t use it. So what can you do? Well, you can build your own, of course! Thankfully there are wonderful projects out there like LanguageTool; they even have a community version! Let’s use that, because that’s what we use and we’re very thankful they exist. You have a 100-page document, great, guess what, there’s a character limit to the API calls. Now you’re thinking about chunking the document. Okay, can do. Wait, is that a French paragraph in the middle of my English document? I can’t send that in the same request, the request is for English… Oh boy. Alright, you’ve got chunking down, and you’ve figured out how to call the API. Are you calling it on the entire 100-page document on every page reload and for every user of the document? Hope you’re running your own LanguageTool server, or you’re going to run out of API usage real fast. But you figured it out: caching, chunking, the whole nine yards. You sent your request aaaaand… Oh, footnote anchors are a problem again, they make the whole word they’re attached to return a spelling error. And those damned deleted track changes are sent to the API?? Let’s say hypothetically you’re still on track and cleaned up your text for the API. Well, now you get to map the results based on the clean text back to your dirty document with its footnote anchors, atomic nodes and deletions, and only then get to finally display the decorations in the right places.
6. Comments seemed like a solved problem
At the time of writing this, most ProseMirror projects out there are using the same tried and true recipe for comments. Just anchor them to a mark and you’re good to go. Oh, sweet summer child, not when you’re building a word processor you don’t. Try breaking the paragraph in the middle of one and see what happens. You now have two anchors, or if you’re lucky, the comment is duplicated! Same with copy-pasting. Want to start a comment in one paragraph and finish it in another, maybe have an image between the two? Well, now you have marks littered all over the place… And that’s just the tip of the iceberg.
7. Footnotes? I know that one I’ve seen them on the demo page of Prosemirror!
We only have the utmost respect for Marijn and their work, and their footnote demo led us on the right track and gave us wonderful insights to build our own (remapping transactions from one sub-editor to the main editor? Genius!). So, as you might have figured out if you’ve been using ProseMirror for a bit, you absolutely, positively should not render a node or nodeview in an absolutely positioned div for editing purposes, and mounting the same editor state in two different views is just asking for trouble. So you’ll have to figure out how to edit footnotes, but you also want to keep them in the same document as the rest of the content, and you also want to receive updates through collab if someone else is editing it at the same time as you do! Oh, and now your clients tell you there can be multiple paragraphs in a footnote? Again, doable, definitely not easy. And we’re not even talking about the layout and rendering of footnotes; that’s a whole other can of worms.
8. Stylesheets were fun too…
All my life I’ve been ignoring stylesheets in word processors; I’m an inline-style kind of person. But it’s not very clean, is it… and stylesheets definitely have their place. And they should matter when you import a Word document, or else they’ll never look right! Your users don’t all have the same headings: some still like Times New Roman 12 (13 is just obnoxious), others have embraced Arial 10, and the bravest of us went with Arimo. Truth is, you just can’t set a single CSS stylesheet for all your users; you need them to be able to have their own house styles. Well, resolving three levels of styling — CSS, custom stylesheets and inline styles — is what you’re in for. You need to store them somewhere, and you need to provide your users a way to edit them, so good luck!
9. Collaboration is… well not too bad really.
We started out with Yjs, as one does, but we quickly realized it wasn’t meant for us. Credit where it’s due, Yjs is a wonderful library and makes so much sense for so many use cases, and we sometimes miss it as it gives so much for free, but in the end we kept having to build workarounds and fight against it (more in this article). Then the same person behind react-prosemirror surprised us with a collab library that soothed all our worries. It’s called Pitter-Patter and you should really check it out! So, really, collab has its challenges but it’s not as bad as the other stuff, at least not anymore.
10. Import and exports are not as simple as you think (when is it ever with a web editor?)
For docx, every node, every mark, stylesheet, numbering configuration, comment, footnote, etc., has to be serialized and deserialized. That’s a lot of work, and you have to try to lose as little data as possible every time. We commit to best effort on round-tripping, and we’ll keep pushing on it — but we won’t be marketing ‘perfect round-tripping’, and that’s a deliberate choice rather than a gap we’re hoping to close. We could have gone in two directions: follow the XML spec to a tee, mirroring OOXML’s every construct in our marks and nodes purely so that a document could make the trip out and back unchanged. That buys fidelity at the cost of a data model shaped by a format instead of by the editing experience, and every feature you build afterwards pays that tax. So we didn’t. This isn’t a tool for editing Word documents online — it’s a standalone product, in the same way Google Docs is a standalone product and doesn’t promise you a byte-identical docx either. The goal is to give your clients the features they actually expect, on a modern, customizable API. Import and export are there to get documents in and out, and we take that seriously; they’re just not what the editor is designed around. As for PDF exports, you’d think: easy, let’s just hit Chrome’s print-to-PDF on the editor (don’t you just love all the things Chrome gives us for free that we can 100% totally use in this project). Wrong. Print the editor as it is and the browser does its own page breaking; headers, footers, page numbers, footnotes, endnotes — those are not part of the editor’s content, at least not where you’d want them to be. And since your users have taken their time to nicely format their document using your pagination engine, they’ll expect to have a similar output to what they’re seeing, and that, my friend, is not even something you can solve by throwing money at the problem and grabbing a PrinceXML license. What we ended up doing: a dedicated export page loads the document, runs our own pagination engine on it, and only then does headless Chromium print it, page by page (well, in batches), and we stitch the pages into one PDF. So yes, it’s Chrome’s print-to-PDF in the end, just not on the editor, and not with Chrome deciding where the pages break. To be honest, though, if you’ve managed to get this far and build a pagination engine like we did, you’ll probably figure out PDFs. If not, build a pagination engine or be alright with the fact that your PDF will be very different from what your users are seeing. Sure, you could convert to docx, feed it to Gotenberg, and Bob’s your uncle, but Gotenberg converts it with LibreOffice, so now a different renderer is laying out your document: different fonts, your page breaks land wherever LibreOffice puts them, and, look at that, all your custom nodes are gone.
I could go on all day.
But I’ll stop here, you get the idea. This is hard, and throwing an LLM at the problem will probably drag you into a rabbit hole of sunk costs, denial and broken deadlines. If you’re still reading this, I hope you enjoyed this insight into our journey. We’re extremely happy with what we’ve built and would love to see it out in the wild. And it’s not all doom and gloom; every now and then we’re happily surprised by one feature basically coming for free out of one of the incredibly tedious bits we’ve described above. There are still a lot of things we’re working on, but as I hope you’ve understood by now, we’ve been very deep into this thing and are very serious about what we’re doing and the requirements of your clients or yourself.