feat: add PDF export for memos using browser print API

This commit is contained in:
faizaan 2026-01-08 22:35:39 +05:30
parent da2dd80e2f
commit 547b25021c
7 changed files with 315 additions and 1 deletions

View File

@ -5,6 +5,7 @@ import {
BookmarkPlusIcon,
CopyIcon,
Edit3Icon,
FileDownIcon,
FileTextIcon,
LinkIcon,
MoreVerticalIcon,
@ -49,6 +50,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
handleToggleMemoStatusClick,
handleCopyLink,
handleCopyContent,
handleExportAsPDF,
handleDeleteMemoClick,
confirmDeleteMemo,
handleRemoveCompletedTaskListItemsClick,
@ -100,6 +102,10 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
<FileTextIcon className="w-4 h-auto" />
{t("memo.copy-content")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportAsPDF}>
<FileDownIcon className="w-4 h-auto" />
{t("memo.export-as-pdf")}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}

View File

@ -12,6 +12,7 @@ import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { removeCompletedTasks } from "@/utils/markdown-manipulation";
import { printMemoAsPDF } from "@/utils/print";
interface UseMemoActionHandlersOptions {
memo: Memo;
@ -95,6 +96,17 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
toast.success(t("message.succeed-copy-content"));
}, [memo.content, t]);
const handleExportAsPDF = useCallback(() => {
try {
printMemoAsPDF(memo);
} catch (error: unknown) {
handleError(error, toast.error, {
context: "Export memo as PDF",
fallbackMessage: "Failed to export PDF. Please allow popups for this site.",
});
}
}, [memo]);
const handleDeleteMemoClick = useCallback(() => {
setDeleteDialogOpen(true);
}, [setDeleteDialogOpen]);
@ -131,6 +143,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
handleToggleMemoStatusClick,
handleCopyLink,
handleCopyContent,
handleExportAsPDF,
handleDeleteMemoClick,
confirmDeleteMemo,
handleRemoveCompletedTaskListItemsClick,

View File

@ -13,6 +13,7 @@ export interface UseMemoActionHandlersReturn {
handleToggleMemoStatusClick: () => Promise<void>;
handleCopyLink: () => void;
handleCopyContent: () => void;
handleExportAsPDF: () => void;
handleDeleteMemoClick: () => void;
confirmDeleteMemo: () => Promise<void>;
handleRemoveCompletedTaskListItemsClick: () => void;

View File

@ -68,7 +68,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
return (
<MemoViewContext.Provider value={contextValue}>
<article className={cn(MEMO_CARD_BASE_CLASSES, className)} ref={cardRef} tabIndex={readonly ? -1 : 0}>
<article className={cn(MEMO_CARD_BASE_CLASSES, className)} data-memo-name={memoData.name} ref={cardRef} tabIndex={readonly ? -1 : 0}>
<MemoHeader
showCreator={props.showCreator}
showVisibility={props.showVisibility}

View File

@ -369,4 +369,207 @@
.leaflet-popup-tip {
background-color: var(--background) !important;
}
/* ========================================
* Print Styles for PDF Export
* Optimized for clean, readable PDF output
* ======================================== */
@media print {
/* Hide UI elements that shouldn't be in PDF */
nav,
header,
footer,
aside,
.no-print,
button,
.memo-action-menu,
[role="menu"],
[role="menubar"],
[role="navigation"],
.memo-header-actions {
display: none !important;
}
/* Page setup */
@page {
margin: 1.5cm 2cm;
size: A4;
}
body {
background: white !important;
color: black !important;
font-size: 12pt;
line-height: 1.6;
}
/* Ensure content flows properly */
* {
background: transparent !important;
color: black !important;
text-shadow: none !important;
box-shadow: none !important;
}
/* Links */
a {
text-decoration: underline;
color: black !important;
}
a[href]:after {
content: " (" attr(href) ")";
font-size: 0.85em;
color: #666;
}
a[href^="#"]:after,
a[href^="javascript:"]:after {
content: "";
}
/* Headings */
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
page-break-inside: avoid;
font-weight: bold;
color: black !important;
}
h1 {
font-size: 24pt;
margin-top: 0;
margin-bottom: 12pt;
}
h2 {
font-size: 18pt;
margin-top: 16pt;
margin-bottom: 10pt;
}
h3 {
font-size: 14pt;
margin-top: 12pt;
margin-bottom: 8pt;
}
/* Paragraphs */
p {
margin-bottom: 12pt;
orphans: 3;
widows: 3;
}
/* Lists */
ul, ol {
margin-bottom: 12pt;
}
li {
page-break-inside: avoid;
}
/* Code blocks */
pre, code {
border: 1px solid #ccc !important;
page-break-inside: avoid;
font-family: "Courier New", Courier, monospace;
}
pre {
padding: 10pt;
margin-bottom: 12pt;
background: #f5f5f5 !important;
white-space: pre-wrap;
word-wrap: break-word;
}
code {
padding: 2pt 4pt;
background: #f0f0f0 !important;
}
/* Blockquotes */
blockquote {
margin: 12pt 0;
padding: 8pt 12pt;
border-left: 3px solid #ccc !important;
background: #f9f9f9 !important;
page-break-inside: avoid;
}
/* Tables */
table {
border-collapse: collapse;
width: 100%;
margin-bottom: 12pt;
page-break-inside: avoid;
}
table, th, td {
border: 1px solid #333 !important;
}
th, td {
padding: 6pt;
text-align: left;
}
th {
background: #f0f0f0 !important;
font-weight: bold;
}
/* Images */
img {
max-width: 100% !important;
page-break-inside: avoid;
page-break-after: avoid;
}
/* Horizontal rules */
hr {
border: none !important;
border-top: 1px solid #ccc !important;
margin: 12pt 0;
}
/* Task lists */
.task-list-item {
list-style: none;
}
.task-list-item input[type="checkbox"] {
margin-right: 0.5em;
}
/* Memo container for single memo print */
.memo-print-container {
padding: 0;
}
/* Memo metadata */
.memo-metadata {
margin-bottom: 16pt;
padding-bottom: 8pt;
border-bottom: 1px solid #ccc !important;
font-size: 10pt;
color: #666 !important;
}
/* Avoid breaking important elements */
.markdown-content,
.memo-content {
page-break-inside: auto;
}
/* Display: Hide markers for interactive elements */
[data-interactive]:after {
content: " [interactive]";
font-size: 0.8em;
color: #999 !important;
}
}
}

View File

@ -154,6 +154,7 @@
},
"copy-content": "Copy Content",
"copy-link": "Copy Link",
"export-as-pdf": "Export as PDF",
"count-memos-in-date": "{{count}} {{memos}} in {{date}}",
"delete-confirm": "Are you sure you want to delete this memo?",
"delete-confirm-description": "This action is irreversible. Attachments, links, and references will also be removed.",

90
web/src/utils/print.ts Normal file
View File

@ -0,0 +1,90 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
/**
* Prints a memo as PDF using the browser's print dialog.
* The browser will show a print preview where users can save as PDF.
*
* @param memo - The memo object to print
*/
export const printMemoAsPDF = (memo: Memo) => {
// Create a new window for printing
const printWindow = window.open("", "_blank");
if (!printWindow) {
throw new Error("Failed to open print window. Please allow popups for this site.");
}
// Get the current theme's styles
const themeStyles = Array.from(document.styleSheets)
.map((styleSheet) => {
try {
return Array.from(styleSheet.cssRules)
.map((rule) => rule.cssText)
.join("\n");
} catch (e) {
// Cross-origin stylesheets may throw an error
return "";
}
})
.join("\n");
// Get memo content
const memoContent = document.querySelector(`[data-memo-name="${memo.name}"] .markdown-content`)?.innerHTML || memo.content;
// Format timestamps
const createTime = memo.createTime ? timestampDate(memo.createTime).toLocaleString() : "Unknown";
const updateTime = memo.updateTime ? timestampDate(memo.updateTime).toLocaleString() : null;
const shouldShowUpdateTime = updateTime && memo.updateTime !== memo.createTime;
// Create the print HTML
const printHTML = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${memo.name} - Memos</title>
<style>
${themeStyles}
</style>
</head>
<body>
<div class="memo-print-container">
<div class="memo-metadata">
<h1 style="margin: 0 0 8pt 0; font-size: 18pt;">Memo</h1>
<div style="font-size: 10pt; color: #666; margin-top: 4pt;">
<p style="margin: 2pt 0;"><strong>Created:</strong> ${createTime}</p>
${shouldShowUpdateTime ? `<p style="margin: 2pt 0;"><strong>Updated:</strong> ${updateTime}</p>` : ""}
</div>
</div>
<div class="markdown-content memo-content">
${memoContent}
</div>
</div>
<script>
// Auto-print when the content is loaded
window.onload = function() {
window.print();
};
// Close window after print dialog closes (print, cancel, or save)
window.onafterprint = function() {
window.close();
};
</script>
</body>
</html>
`;
// Write the HTML to the new window
printWindow.document.write(printHTML);
printWindow.document.close();
};
/**
* Prints the current page/memo using the browser's print dialog.
* This is a simpler alternative that doesn't create a new window.
*/
export const printCurrentPage = () => {
window.print();
};