DEV Community

Cover image for How I built Word-style A4 pages for TipTap without touching the document
Huy Phan
Huy Phan

Posted on AI-assisted

How I built Word-style A4 pages for TipTap without touching the document

If you've built an editor with TipTap / ProseMirror, you've probably heard this request: "Make it look like Word. Show real A4 pages, and print exactly what I see."

Sounds simple, but a contenteditable is one endless column of content. It has no idea what a page is.

This post walks through how I added A4 pagination to my open-source editor, Text Editor React, and packaged it as a free extension: tiptap-a4-pagination.

👉 Live demo: https://text-editor-react-neon.vercel.app/


The options I found

Option Problem
Tiptap Pages (official) Paid Pro extension, private npm registry
Community extensions Inject global CSS (change table display, word-break: break-all, force padding/width on the editor), which broke my existing styles
One editor per page Cursor movement, undo and selections across pages become a nightmare

I wanted one thing: don't modify the document, don't touch the user's CSS. editor.getHTML() should stay exactly as clean as before.

The idea: spacers as decorations

ProseMirror has decorations: elements that are rendered but are not part of the document. So:

  1. Measure every top-level block (paragraphs, headings, tables, images…).
  2. When a block would cross the bottom of a page, insert a widget decoration right before it.
  3. Make the widget exactly as tall as the rest of the page + bottom margin + the gray gap + the next page's top margin.

The block lands at the top of the next sheet, and visually you get separate pages. The document doesn't change by a single byte.

Decoration.widget(pos, () => createSpacer(pageBreak), { side: -1, ignoreSelection: true })
Enter fullscreen mode Exit fullscreen mode

The layout is recomputed whenever the editor's height changes (typing, images loading, fonts arriving) with a ResizeObserver and requestAnimationFrame.

Don't trust the current positions

My first attempt used each block's getBoundingClientRect().top. The problem: the spacers I had already inserted shift those positions. Measure again, get a different answer, move the spacers, measure again… an endless loop.

What works is to rebuild the layout from heights and margins only, as if there were no spacers at all:

let cursor: number | null = null   // bottom of the previous block
let pageEnd = layout.contentHeight

for (const block of blocks) {
  // Adjacent vertical margins collapse, like in CSS
  const top = cursor === null
    ? block.top
    : cursor + Math.max(prevMarginBottom, block.marginTop)
  const bottom = top + block.height

  if (bottom > pageEnd && top > pageStart) {
    // Spacer before this block: it starts at the top of the next page
    breaks.push({ pos: block.pos, height: nextPageTop - block.marginTop - (cursor + prevMarginBottom) })
    pageEnd += pitch
  }
  cursor = bottom
}
Enter fullscreen mode Exit fullscreen mode

This function is pure: blocks in, page breaks out, no DOM. That made it easy to unit test, and the package has 13 tests for this part alone.

The traps I fell into

1. Margin collapse

Once a spacer sits between two paragraphs, their margins stop collapsing, and everything is off by a few pixels. On top of that, the first block's top margin "leaks" out of the editor, which shifted my origin by 16px. The fix was to measure from the top of the page's content area (the page's padding-top), not from the editor element.

2. Floated images

An image with float: left (text wrapping) doesn't push the next block down: the next paragraph sits beside it. The layout has to skip a floated block's height when placing the following block.

3. Splitting tables across pages

Pushing a long table to the next page as a whole looks terrible. Instead, inside a table the spacer is a <tr>: a widget decoration placed between two row nodes.

The biggest bug here: I gave the spacer cell colSpan = 1000, "to be safe". With table-layout: fixed, the browser created 1000 phantom columns and squeezed the real ones to almost nothing, so text stacked one character per line. The fix: use the real column count, TableMap.get(table).width from prosemirror-tables.

4. Rowspan

Inserting a spacer row in the middle of a cell with rowspan breaks the table instantly. Rows joined by a rowspan are grouped into one unbreakable group, and page breaks only go between groups.

5. Repeating the header row

Like Word, when a table continues on the next page, its header row shows again. I copy the first row's DOM (with the measured width of each cell, so the columns line up) into the end of the spacer row.

6. Zoom

Later I added a Google Docs–style zoom (50–200%) with a CSS transform: scale() on the page. Every getBoundingClientRect() suddenly returned scaled values, and page breaks moved around with the zoom level. Now the extension divides every measurement by the page's on-screen scale:

export function elementScale (element: HTMLElement): number {
  return element.getBoundingClientRect().width / element.offsetWidth || 1
}
Enter fullscreen mode Exit fullscreen mode

offsetWidth ignores transforms, so the ratio is the zoom. Page breaks are now identical at every level.

PDF export without a server

The project used to export PDFs through a Puppeteer backend. Now it prints through the browser (window.print() on a hidden iframe containing only the pages):

  • @page { size: A4; margin: ... } comes from the page settings.
  • Spacers are removed and break-before: page goes on the block right after each one, so the PDF breaks exactly where the editor does.
  • Table header rows move into a <thead>, and Chrome repeats them on every printed page.
  • Headers, footers and page numbers use CSS page margin boxes (Chrome 131+):
@page {
  @top-left     { content: "Quarterly report"; }
  @bottom-right { content: "Page " counter(page) " of " counter(pages); }
}
Enter fullscreen mode Exit fullscreen mode

Use it in your project

npm install tiptap-a4-pagination
Enter fullscreen mode Exit fullscreen mode
import { Pagination, createPageLayout } from 'tiptap-a4-pagination'
import 'tiptap-a4-pagination/styles.css'

// Margins in mm
const layout = createPageLayout({ size: 'A4', marginTop: 25.4, marginBottom: 25.4 })

const editor = useEditor({
  extensions: [StarterKit, Pagination.configure({ pageSelector: '.page' })]
})

useEffect(() => { editor?.commands.setPageLayout(layout) }, [editor])
Enter fullscreen mode Exit fullscreen mode

About 5 KB gzipped, TipTap 3 only. Full docs are in the package README.

Limitations

  • A single paragraph taller than the space left on a page moves to the next page as a whole; paragraphs are not split across two pages yet.
  • A table row taller than a whole page overflows into the gap.

What else is in the editor

Comments in the margin, link sharing with real-time editing (peer-to-peer over WebRTC with Yjs, no backend), headers and footers, opening .docx files, Google Docs paste that keeps checklists and tables, slash commands, find & replace, and zoom. All MIT.


I'd love feedback, especially documents that break the layout. If you find it useful, a ⭐ on GitHub would mean a lot 🙏

Top comments (0)