Open https://quirekit.com and the first thing you see is not a screenshot. It's an editor with actual pages: type, press Enter, watch a table split across a page break, switch between Canvas and SVG rendering. All live.
I've been building ProseMirror-based products for three years. The request I could never really deliver was "can it paginate like Word / Google Docs?" In June I gave up on working around it and started over. Four months later that turned into QuireKit, and the open-source parts are MIT.
The problem with contenteditable and pages
The browser is great at laying out one endless page. It has no idea about sheets of paper: where a paragraph should break, which table row moves to the next page, what the page number is.
The usual trick is to let the browser lay out, measure heights, and cut the result into pages afterwards. It works for a demo. Then the content changes and everything has to be re-measured and re-cut, and the caret and selection have to follow your fake page structure around. I spent a long time patching that kind of setup. It never got solid.
Keep ProseMirror, replace the view
ProseMirror splits an editor very cleanly: document model, transactions, plugins, commands, undo, input rules, keymaps and collaboration don't care how anything is displayed. Only the last layer, EditorView, is tied to contenteditable.
So QuireKit keeps everything above that line and replaces the view. The editor API is modeled on Tiptap, so porting a Tiptap extension is mostly changing the import from @tiptap/core to @quirekit/core.
There are three views on the same editor state, sharing extensions, commands and plugins:
| View | Who lays out | Used for |
|---|---|---|
| Canvas / SVG | QuireKit's own engine | the paged document editor |
| HTML view | the browser, but caret and selection are ours | the block editor |
| DOM view | native ProseMirror EditorView
|
small inputs like comments |
What the paged editor has to do itself
Once you drop the DOM, everything the browser used to give you for free is now your job.
Layout and pagination. Measuring text, breaking lines, placing paragraphs, lists, tables and images into page frames. I ended up with one recursive routine for every container: the body, a paragraph (split by lines), a table (split by rows), and a table cell, which is itself a container paginated by the same routine. The parent decides where the page breaks. A child only reports "fits", "overflows" or "needs a new page", plus whatever it needs to continue on the next page: unclosed marks, list counters, header rows to repeat.
The nice side effect: a custom container block gets cross-page behaviour for free if it goes through that routine.
Incremental layout. Typing one character doesn't re-layout the whole document, and only visible pages keep a drawing surface, so long documents don't hold memory while you scroll.
One display list, two backends. Layout turns into a list of drawing commands that doesn't know its target. Canvas 2D or SVG then paints it. Hit testing, caret and selection geometry all come from the same layout, so both backends behave the same.
The engine doesn't know what Word is. It only knows how to lay things out. Paragraph indents, page sizes, table structure and so on come in through a set of "document profile" interfaces, so the same engine can lay out a plain web document or a Word-style one.
IME on a canvas was the hardest part
There's no editable DOM on a canvas, so keyboard input, clipboard and IME all go through a hidden input proxy placed at the caret.
IME (Chinese, Japanese, Korean input) is where it gets painful. While you're composing, the uncommitted text has to appear at the caret and push the rest of the line along. But it must not land in undo history, and it must not be sent to collaborators.
What I do: during composition, layout and painting use a temporary state that is never dispatched, as if the preedit text were really in the document. On compositionend, a normal transaction is committed.
The detail that bit me: decorations have to be computed against that temporary state too. If you compute them against the committed state, highlights and field backgrounds slide onto the neighbouring characters while someone is typing.
Browsers also disagree a lot here. event.data is sometimes empty or stale, composition sometimes ends with an input event instead of compositionend, and you can get a duplicate insert right after commit. That state machine lives in its own package, @quirekit/ime, independent of rendering, so other non-contenteditable editors can use it.
A selection that survives losing focus
Not every document needs pages. For notes and knowledge bases the block editor uses the HTML view: the browser does layout, but caret and selection are drawn from model geometry and don't depend on focus.
That fixes something that annoyed me for years: select some text, click into the toolbar to type a link or pick a color, and the native selection greys out or disappears exactly when you want to act on it. Here there's only ever one caret, and the selection stays put.
The Markdown side: the source is the document
There's a second line built on CodeMirror and Lezer for Markdown. The Markdown text itself is the document; there's no JSON round trip, so lines you didn't touch are byte-for-byte unchanged when you save. MD Editor gives you four views of the same source (document, live preview, source, reading), and the first three are all editable.
Source-first means re-parsing on every keystroke, which gets expensive for big files. I added "reuse proofs" to Lezer Markdown's incremental parse: the parser records which ranges it reused as-is, and the editor skips those regions instead of comparing old and new trees node by node.
On a 1 MiB document, over 20 consecutive edits:
| Work | Before | After |
|---|---|---|
| new tree fragment visits | 107,760 | 1,380 (−98.7%) |
| old tree path comparisons | 153,940 | 660 (−99.6%) |
Results are still checked against a full parse, and wherever the proof doesn't hold it falls back to the full comparison, so correctness doesn't change.
What's open source
MIT: the two editor frameworks, the layout engine and all views, the paged, block and Markdown editors, the UI kit, and the standalone pieces (math editor with LaTeX/OMML conversion, the IME engine, Mermaid/PlantUML/Graphviz diagram editor, flowchart, mind map, whiteboard, charts, data views).
DOCX import/export and PDF/OFD export are a separate commercial add-on. They also run fully in the browser with no server, and they're free for development and evaluation. You don't need them to use the editors.
Try it
It's an early alpha. APIs will change and there are rough edges.
The paged editor is one call, no Word preset needed:
npm install @quirekit/classic-editor@alpha
import { createClassicEditor } from '@quirekit/classic-editor'
import '@quirekit/classic-editor/styles.css'
const editor = createClassicEditor({ element: document.querySelector('#editor')! })
The block editor and the Markdown editor are @quirekit/block-editor and @quirekit/md-editor, with Vue components exported from their /vue entries.
- Live demo: https://quirekit.com
- Docs: https://docs.quirekit.com
- GitHub: https://github.com/quirekit/quirekit
If you've fought pagination or IME in an editor, I'd love to hear what broke for you.
I'm not a native English speaker and used an AI assistant to help with the wording. The project and the war stories are my own.





Top comments (0)