mirror of https://github.com/usememos/memos.git
feat: add PDF export for memos using browser print API
This commit is contained in:
parent
da2dd80e2f
commit
547b25021c
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export interface UseMemoActionHandlersReturn {
|
|||
handleToggleMemoStatusClick: () => Promise<void>;
|
||||
handleCopyLink: () => void;
|
||||
handleCopyContent: () => void;
|
||||
handleExportAsPDF: () => void;
|
||||
handleDeleteMemoClick: () => void;
|
||||
confirmDeleteMemo: () => Promise<void>;
|
||||
handleRemoveCompletedTaskListItemsClick: () => void;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
Loading…
Reference in New Issue