0

Phân trang A4 cho TipTap mà không đụng vào tài liệu: mình đã làm thế nào

Phân trang A4 cho TipTap mà không đụng vào tài liệu: mình đã làm thế nào

Nếu bạn từng làm editor bằng TipTap / ProseMirror, chắc bạn đã gặp yêu cầu kiểu: "Cho người dùng soạn văn bản giống Word, thấy rõ từng trang A4, in ra PDF đúng như trên màn hình". Nghe thì đơn giản, nhưng contenteditable vốn chỉ là một khối nội dung dài vô tận — không có khái niệm "trang".

Bài này chia sẻ cách mình làm phân trang A4 cho dự án mã nguồn mở Text Editor React, và đóng gói thành extension miễn phí tiptap-a4-pagination.

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


1. Các lựa chọn có sẵn

Lựa chọn Vấn đề
Tiptap Pages (chính chủ) Gói Pro trả phí, cần registry npm riêng
Extension cộng đồng Tự chèn CSS toàn cục (đổi display của bảng, word-break: break-all, ép padding/width lên editor…) → vỡ giao diện có sẵn
Tách tài liệu thành nhiều editor, mỗi trang một editor Con trỏ, undo, chọn văn bản qua trang… rất phức tạp

Mình muốn một thứ: không sửa tài liệu, không sửa CSS của người dùng, editor.getHTML() vẫn sạch như cũ.

2. Ý tưởng: chèn "khoảng đệm" bằng decoration

ProseMirror có decoration — phần tử chỉ để hiển thị, không nằm trong tài liệu. Ý tưởng:

  1. Đo chiều cao từng khối cấp cao nhất (đoạn văn, tiêu đề, bảng, ảnh…).
  2. Khối nào vượt quá đáy trang → chèn một widget decoration ngay trước nó.
  3. Widget cao đúng bằng: phần trống còn lại của trang + lề dưới + khoảng hở xám + lề trên trang sau.

Kết quả: khối đó bị đẩy xuống đúng đầu trang sau, nhìn như từng tờ giấy riêng. Tài liệu không đổi một byte.

Decoration.widget(pos, () => createSpacer(height), { side: -1 })

Đo lại mỗi khi chiều cao editor đổi (gõ chữ, ảnh tải xong, font load…) bằng ResizeObserver + requestAnimationFrame.

3. Thuật toán: đừng tin vị trí hiện tại

Sai lầm đầu tiên của mình: dùng getBoundingClientRect().top của từng khối. Vấn đề là các khoảng đệm đã chèn làm lệch vị trí → đo lại thì ra kết quả khác → chèn lại → vòng lặp vô tận.

Cách đúng: dựng lại bố cục từ chiều cao và margin của từng khối, coi như chưa có khoảng đệm nào:

let cursor = null          // đáy khối trước
let pageEnd = contentHeight

for (const block of blocks) {
  // margin của 2 khối liền nhau bị gộp (margin collapse) như CSS
  let top = cursor === null ? block.top : cursor + Math.max(prevMarginBottom, block.marginTop)
  const bottom = top + block.height

  if (bottom > pageEnd && top > pageStart) {
    // chèn khoảng đệm, khối bắt đầu ở đầu trang sau
    breaks.push({ pos: block.pos, height: nextPageTop - block.marginTop - (cursor + prevMarginBottom) })
    pageEnd += pageHeight + gap
  }
  cursor = bottom
}

Hàm này thuần (không đụng DOM) nên viết unit test rất dễ — trong repo có 13 test cho riêng phần này.

4. Những cái bẫy mình đã dẫm

4.1. Margin collapse

Khi có khoảng đệm chen giữa, margin của 2 đoạn văn không gộp nữa → mọi thứ lệch vài pixel. Ngoài ra margin-top của khối đầu tiên còn "lọt" ra ngoài editor, làm mốc tính sai 16px. Fix: lấy mốc là mép trên vùng nội dung của trang (padding-top của khung trang), không phải mép editor.

4.2. Ảnh float (chữ bao quanh)

Ảnh float: left không đẩy khối sau xuống — đoạn văn tiếp theo nằm bên cạnh nó. Thuật toán phải bỏ qua chiều cao của khối float khi tính vị trí khối kế tiếp.

4.3. Tách bảng qua trang

Bảng dài mà đẩy nguyên khối sang trang sau thì rất xấu. Giải pháp: khoảng đệm chính là một hàng <tr> chèn giữa 2 hàng của bảng (widget decoration đặt ở vị trí giữa các row node).

Bẫy lớn nhất ở đây: ô đệm mình đặt colSpan = 1000 cho chắc. Với table-layout: fixed, trình duyệt tạo ra 1000 cột ảo và ép các cột thật về gần 0 — chữ dựng đứng thành từng ký tự. Fix: lấy đúng số cột bằng TableMap.get(table).width của prosemirror-tables.

4.4. Rowspan

Nếu chèn hàng đệm vào giữa một ô rowspan, bố cục bảng vỡ ngay. Mình gom các hàng dính nhau bởi rowspan thành một nhóm không tách được, chỉ ngắt trang giữa các nhóm.

4.5. Lặp lại hàng tiêu đề

Giống Word: bảng tách qua trang thì trang sau hiện lại hàng tiêu đề. Mình copy DOM của hàng đầu (kèm độ rộng từng ô đo được để cột thẳng hàng) vào cuối hàng đệm.

5. In ra PDF không cần server

Ban đầu dự án xuất PDF qua backend Puppeteer. Sau mình chuyển sang in bằng trình duyệt (window.print() trên một iframe chỉ chứa trang giấy):

  • @page { size: A4; margin: ... } lấy từ cài đặt trang.
  • Bỏ các khoảng đệm, đặt break-before: page lên khối ngay sau mỗi khoảng đệm → PDF ngắt trang đúng chỗ editor ngắt.
  • Đưa hàng tiêu đề bảng vào <thead> → Chrome tự lặp lại ở mỗi trang in.
  • Chân trang có copyright + "Trang X / Y" bằng CSS page margin boxes (Chrome 131+):
@page {
  @bottom-right { content: "Trang " counter(page) " / " counter(pages); }
}

6. Dùng trong dự án của bạn

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

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

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

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

Khoảng 5 KB (gzip), chỉ phụ thuộc TipTap 3. Hướng dẫn đầy đủ ở README của package.

7. Hạn chế và dự định

  • Một đoạn văn dài hơn phần còn lại của trang sẽ bị đẩy nguyên sang trang sau (chưa tách đoạn văn giữa 2 trang).
  • Một hàng bảng cao hơn cả trang sẽ tràn qua khoảng hở.

Ngoài phân trang, editor còn có: dán từ Google Docs giữ checklist/bảng/thụt lề, ảnh bao chữ, công thức KaTeX (tải lười), tự lưu IndexedDB, làm việc nhóm qua WebRTC, giao diện Anh/Việt. Lighthouse production: 100/100/100/100 trên desktop.


Dự án là mã nguồn mở (MIT). Mọi người dùng thử, báo lỗi hoặc góp ý giúp mình nhé — nếu thấy hữu ích thì cho mình xin một ⭐ trên GitHub 🙏


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí