Engineering
What Reading Quill's Source Code Taught Me About Editors
Deltas, blots, clipboard pain, and selection state — tracing Quill internals and what carried over into Lextrix.
I had used Quill in a couple of projects before I ever opened its repository.
You npm install, follow the docs, customise the toolbar, fight paste behaviour once, ship. Quill is good at letting you do that. What it does not automatically give you is a mental model of what happens between a keypress and the DOM updating.
I wanted that model because bugs were getting harder to debug. Selection jumping after format toggles. Undo doing almost the right thing. Paste from Google Docs leaving phantom newlines. I could patch symptoms, but I did not understand the system.
So I spent a few evenings reading Quill source — not cover to cover, but tracing specific flows until they clicked. That reading directly led to why I started building Lextrix. This post is the technical half of that story.
What Quill optimises for
Quill is a rich-text editor built around a document model called Deltas. Instead of treating the editor as "a div of HTML we hope is sane," changes are described as operations:
- insert — add text or embed with optional attributes
- retain — skip N characters, optionally apply format
- delete — remove N characters
A Delta is a JSON-serialisable list of these ops. User edits become Delta transformations. Undo becomes applying an inverse Delta. Collaborative editing (with appropriate transform rules) becomes merging concurrent Deltas.
That sounds abstract until you see a concrete example. Suppose the document is Hello and the user selects ell and makes it bold. You might express the change roughly as:
{
"ops": [
{ "retain": 1 },
{ "retain": 3, "attributes": { "bold": true } },
{ "retain": 1 }
]
}Exact ops depend on selection indices and internal representation, but the shape is what matters: index-based operations with attributes, not "wrap this substring in <b> and hope."
Once I internalised that, a lot of Quill API surface made sense — getContents(), setContents(), updateContents(), format toggles as attribute changes on retain ops.
Blots: the DOM tree with rules
Quill's DOM layer uses Blots — objects representing nodes in the editor tree. Block blots, inline blots, embed blots. Each knows how to create DOM, read value back, and interact with the registry.
This was the second big unlock for me.
Contenteditable browsers give you a soup of nodes. Quill constrains the soup by saying: these node types exist, they nest in allowed ways, unknown stuff gets normalised on ingest. The blot layer is where paste sanitisation, cursor behaviour, and format boundaries actually live — not in the toolbar React component you wrote.
When I traced bold formatting, I did not start in the toolbar button handler. I started at the format module, followed into the blot that applies inline attributes, watched how it cooperates with selection. Boring path. Useful path.
I noticed that if you only read the public API files, you miss why the API is shaped that way.
Modules: clipboard, keyboard, toolbar
Quill splits cross-cutting behaviour into modules registered on the editor instance — clipboard, keyboard shortcuts, history (undo), toolbar bindings, etc.
Two observations:
- Modules are not React components. They hook into editor lifecycle and events. Your UI framework sits outside.
- Clipboard is where editors go to suffer. Paste from Word, Slack, browser quirks, image drops — the clipboard module normalises external HTML into something the document model accepts.
I spent one evening only on clipboard code. I do not remember every branch. I remember thinking: "Ah, so this is why my custom format disappeared on paste — it never mapped through the matchers."
That single realisation saved me days of random CSS attempts later.
Selection is state, not a DOM accident
Before reading source, I treated selection as whatever window.getSelection() returned. Quill tracks selection indices relative to the document model and syncs with the DOM.
When those layers desync, you get the classic bugs — caret before a line break visually but after logically, format apply targeting wrong range, undo restoring content but not selection.
Reading how Quill normalises selection after operations changed how I think about editor state generally. The DOM is a render target; the document + selection indices are supposed to be truth. Lextrix still desyncs in edge cases I have not fully traced — HTML-in-contenteditable as source of truth falls apart under undo pressure, and I am not convinced I have eliminated every path where it sneaks back in.
This is the main idea I carried into Lextrix's architecture — explicit change processing and clear ownership of document state, even though Lextrix uses a ChangeSet layer with compose/diff/invert/transform rather than Quill's Delta naming. Whether I got the split right is still an open question; nested lists and paste are where it shows.
Same family of problem. Different implementation choices shaped by TypeScript modules and what I needed while building.
What I deliberately did not do
I did not memorise every file. Quill is not small. The goal was a map, not trivia:
- Where do ops enter the system?
- Where is DOM updated?
- Where would I add a custom format?
- Where does undo hook in?
Four questions. I answered each by following one stack trace end to end.
If you are reading ProseMirror instead, swap "steps" for "ops" and "schema" for "blots" — the reading strategy still works.
Quill vs what I wanted (and why Lextrix exists)
Quill is mature and battle-tested. I am not writing this to convince you to replace it.
What I wanted personally:
| Need | Quill | What I built in Lextrix |
|---|---|---|
| TypeScript-first internals | Mostly JS heritage | TS packages from the start |
| Package-level composition | Monolithic npm package ergonomics | lextrix-dom, lextrix-change, etc. |
| Learning the full stack | Use as dependency | Read + rebuild to learn |
| Long-term extensibility | Good, with community patterns | lxr/* registry paths, explicit modules |
None of that means Quill failed me. It means my side-project goals were orthogonal to "pick the best editor for production tomorrow."
For work projects I would still evaluate Quill, TipTap, ProseMirror, Slate on requirements. This post is about learning from source, not picking a winner for production tomorrow.
If you want a learning path: read one of them deeply. Quill's Delta + Blot split was a readable on-ramp for me.
Notes I kept after reading
I stopped trusting HTML diffing once undo had to be reliable — document ops were the only approach that held up for me so far, though invert for nested formats is still where I find bugs.
Hardcoding document.execCommand or manual <span style> insertion did not scale; a registry for formats and embeds made more sense once I saw how Quill wired it.
React re-renders should not be the editor's internal heartbeat. Quill keeps the UI framework outside core; that matched how I wanted to build Lextrix.
Paste took one full evening in source and still was not finished. I stopped treating it as a CSS problem after that.
One vertical slice at a time worked better than random file hopping: toolbar → format → blot → DOM → change event, then paste, undo, selection.
Mistakes I made while reading
- Reading randomly. First night I jumped file to file. Second night I picked "bold toggle" and finished the trace. Night two was worth five of night one.
- Assuming names match mental models. Quill's
retainconfused me until I stopped thinking in HTML strings. - Ignoring tests. Quill's test files show expected op sequences. Faster than guessing from implementation alone.
- Not taking notes. I repeated traces a week later. Short diagrams in a scratch file would have helped.
How this connects to Lextrix concretely
When I implemented invert for nested list items in Lextrix, I knew why invert mattered — Quill's history module is the reference behaviour users expect even if they never read source. Getting it right took longer than I expected; there are still cases filed as issues.
When I split clipboard handling into lextrix-modules, I thought I knew where matchers belong — not scattered in app code. Paste from Word and Slack still surfaces formats I had not mapped yet.
When I resisted storing raw HTML as canonical state, I knew what I was avoiding — bugs I had already paid for in production patches. Serialization to Markdown and MDX introduced a different set of edge cases I am still working through.
Lextrix is not a Quill rewrite. It is what happened after Quill taught me the questions. The answers are mine, incomplete, open source, and still evolving.
I started reading Quill because I was annoyed by bugs. I kept reading because good abstractions are interesting. I built Lextrix because reading alone was not enough for what I wanted to learn.
Compare on GitHub, disagree, open issues — that is how this gets better than anything I could write alone. For the project story after this post, see Why I Started Building Lextrix. Docs and playground are still catching up to code; read source with that expectation.