Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@tiptap/pm": "^3.20.1",
"@tiptap/react": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"html2pdf.js": "^0.14.0",
"lucide-react": "^0.511.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
Expand Down
12 changes: 12 additions & 0 deletions src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ import {
Link,
ImageIcon,
MinusIcon,
FileDown,
} from "lucide-react";
import ColorPicker from "./ColorPicker";
import LinkPopover from "./LinkPopover";
import ImagePopover from "./ImagePopover";
import { exportToPdf } from "../utils/exportToPdf";

const FONT_FAMILIES = [
"Arial",
Expand Down Expand Up @@ -519,6 +521,16 @@ function Toolbar({ editor }: ToolbarProps) {
>
<MinusIcon size={18} />
</ToolbarButton>

<ToolbarDivider />

{/* Export to PDF */}
<ToolbarButton
title="Export to PDF"
onClick={() => exportToPdf(editor)}
Comment on lines +528 to +530
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

>
<FileDown size={18} />
</ToolbarButton>
</div>
</>
);
Expand Down
68 changes: 68 additions & 0 deletions src/utils/exportToPdf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { Editor } from "@tiptap/react";
import html2pdf from "html2pdf.js";
Comment on lines +1 to +2
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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;
Comment on lines +4 to +41
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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();
Comment on lines +45 to +67
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

}