feat: add export to PDF#6
Conversation
Add a toolbar button to export the editor content as a PDF file with matching styles, US Letter format, and proper margins. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR ReviewScoresPR Quality NotesPR Description & Scope
Commit Messages & History
PR Size & Focus
Self-Review & Polish
TL;DRThis PR adds client-side PDF export by wiring a new toolbar button to a Merge Recommendation: Approve with suggestions — the feature is directionally useful, but the current implementation has material fidelity, accessibility, and reliability gaps. Focus Areas for Architect Review
💬 Minor / Nitpicks (3)
Based on 226390e...ea0d3a1 |
| import type { Editor } from "@tiptap/react"; | ||
| import html2pdf from "html2pdf.js"; |
There was a problem hiding this comment.
[Enterprise Quality] 🟠 html2pdf.js is imported at module scope and Toolbar pulls that module into the initial editor render, so every editor session pays the download/parse cost of the PDF stack even if export is never used. For a client-only editor this is avoidable startup regression on all users; move the export path behind a dynamic import() so the chunk is fetched only when the button is pressed.
Related: package.json:26, src/components/Toolbar.tsx:30
| const EDITOR_STYLES = ` | ||
| .pdf-export-container { | ||
| font-family: Arial, sans-serif; | ||
| font-size: 11pt; | ||
| line-height: 1.5; | ||
| color: #000; | ||
| } | ||
| .pdf-export-container ul { | ||
| list-style-type: disc; | ||
| padding-left: 1.5em; | ||
| margin: 0.5em 0; | ||
| } | ||
| .pdf-export-container ol { | ||
| list-style-type: decimal; | ||
| padding-left: 1.5em; | ||
| margin: 0.5em 0; | ||
| } | ||
| .pdf-export-container ul ul { | ||
| list-style-type: circle; | ||
| } | ||
| .pdf-export-container ul ul ul { | ||
| list-style-type: square; | ||
| } | ||
| .pdf-export-container li { | ||
| margin: 0.2em 0; | ||
| } | ||
| .pdf-export-container a { | ||
| color: #1a73e8; | ||
| text-decoration: underline; | ||
| } | ||
| .pdf-export-container img { | ||
| max-width: 100%; | ||
| height: auto; | ||
| } | ||
| .pdf-export-container hr { | ||
| border: none; | ||
| border-top: 1px solid #dadce0; | ||
| margin: 1em 0; |
There was a problem hiding this comment.
[Code Quality] 🟠 src/utils/exportToPdf.ts:4-41 adds a second hardcoded source of truth for editor presentation (Arial, 11pt, link color, rule color, list spacing), while the toolbar already carries some of those defaults in src/components/Toolbar.tsx:112-114. That duplication will drift the first time editor styling changes, and PDF parity will become a manual sync problem instead of a shared configuration.
Related: src/components/Toolbar.tsx:112-114
| export function exportToPdf(editor: Editor): void { | ||
| const editorHtml = editor.getHTML(); | ||
|
|
||
| const wrapper = document.createElement("div"); | ||
|
|
||
| const styleElement = document.createElement("style"); | ||
| styleElement.textContent = EDITOR_STYLES; | ||
| wrapper.appendChild(styleElement); | ||
|
|
||
| const content = document.createElement("div"); | ||
| content.className = "pdf-export-container"; | ||
| content.innerHTML = editorHtml; | ||
| wrapper.appendChild(content); | ||
|
|
||
| const options = { | ||
| margin: [0.5, 1, 0.5, 1] as [number, number, number, number], | ||
| filename: "document.pdf", | ||
| image: { type: "jpeg" as const, quality: 0.98 }, | ||
| html2canvas: { scale: 2, useCORS: true }, | ||
| jsPDF: { unit: "in", format: "letter", orientation: "portrait" as const }, | ||
| }; | ||
|
|
||
| html2pdf().set(options).from(wrapper).save(); |
There was a problem hiding this comment.
[Correctness] 🟠 src/utils/exportToPdf.ts:45-67 rebuilds the PDF from editor.getHTML() and lets html2pdf paginate that fresh DOM, instead of exporting the already-paginated page view the user is editing. In this editor, page-boundary avoidance is applied at render time, so any heading/paragraph that was visually pushed to the next page can still be split or land on a different page in the PDF. This shows up as soon as a document spans multiple pages or has a block near the bottom of a page.
[Code Quality] 🟠 src/utils/exportToPdf.ts:45-67 couples two responsibilities in one utility: it reaches into Tiptap to extract HTML and also owns the PDF rendering/saving flow. That makes the module harder to reuse outside this toolbar path and raises the cost of testing or adding other export entry points, because callers have to supply a full Editor instead of plain exportable content.
Related: src/components/Toolbar.tsx:530
[Enterprise Quality] 🟠 exportToPdf is fire-and-forget: it kicks off html2pdf().save() and the toolbar discards the returned async chain. When rasterization fails because of large documents, memory pressure, or cross-origin images without permissive CORS, the user gets no feedback and the app cannot disable/retry the button to prevent overlapping exports; return a promise, await it in the toolbar, and surface a visible failure state.
Related: src/components/Toolbar.tsx:528-530
[Correctness] 🟠 src/utils/exportToPdf.ts:62-67 assumes useCORS: true is enough for images, but there is no proxy/fallback path. The existing image flow accepts arbitrary third-party URLs, and html2canvas skips cross-origin images that are not CORS-enabled, so documents that preview correctly in the editor can export with missing images whenever the user inserted a normal external image URL.
[Correctness] 🟠 src/utils/exportToPdf.ts:62-67 renders the entire document into a single canvas at scale: 2. With this editor’s ~960px content height per page, Chrome’s 32,767px canvas-height limit is hit at roughly 17 pages, after which html2canvas/html2pdf can return blank or truncated output. Long documents are therefore exportable in the editor UI but not reliably exportable as PDF.
[Enterprise Quality] 🟠 The PDF output is rasterized through html2canvas into JPEG-backed pages, so exported documents will not contain selectable/searchable text, semantic headings, or accessible links. That is a material accessibility gap for anyone consuming the PDF with screen readers or needing copy/search, and it usually requires a print/DOM-based or server-side renderer rather than a canvas snapshot to fix.
| <ToolbarButton | ||
| title="Export to PDF" | ||
| onClick={() => exportToPdf(editor)} |
There was a problem hiding this comment.
[Enterprise Quality] 🟠 The new export control inherits mouse-only activation from ToolbarButton: the action is bound to onMouseDown, but keyboard activation of a native button dispatches click, not mousedown. That means Enter/Space cannot trigger export, and the icon-only button still relies on title instead of a stable programmatic label; wire activation through onClick and add aria-label={title} while keeping onMouseDown only for focus preservation.
ℹ️ Original location:
src/components/Toolbar.tsx:64-85— moved to nearest diff line for GitHub compatibility.
Summary
html2pdf.jslibraryexportToPdfutility extracts editor HTML, applies matching CSS styles (fonts, lists, links, images), and generates a US Letter PDF with proper margins (0.5" top/bottom, 1" left/right)Test plan
document.pdffile is downloaded🤖 Generated with Claude Code