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
6 changes: 6 additions & 0 deletions web/src/components/MemoActionMenu/MemoActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
BookmarkPlusIcon,
CopyIcon,
Edit3Icon,
FileDownIcon,
FileTextIcon,
LinkIcon,
MoreVerticalIcon,
Expand Down Expand Up @@ -49,6 +50,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
handleToggleMemoStatusClick,
handleCopyLink,
handleCopyContent,
handleExportAsPDF,
handleDeleteMemoClick,
confirmDeleteMemo,
handleRemoveCompletedTaskListItemsClick,
Expand Down Expand Up @@ -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>
)}
Expand Down
13 changes: 13 additions & 0 deletions web/src/components/MemoActionMenu/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -131,6 +143,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
handleToggleMemoStatusClick,
handleCopyLink,
handleCopyContent,
handleExportAsPDF,
handleDeleteMemoClick,
confirmDeleteMemo,
handleRemoveCompletedTaskListItemsClick,
Expand Down
1 change: 1 addition & 0 deletions web/src/components/MemoActionMenu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface UseMemoActionHandlersReturn {
handleToggleMemoStatusClick: () => Promise<void>;
handleCopyLink: () => void;
handleCopyContent: () => void;
handleExportAsPDF: () => void;
handleDeleteMemoClick: () => void;
confirmDeleteMemo: () => Promise<void>;
handleRemoveCompletedTaskListItemsClick: () => void;
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/MemoView/MemoView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
203 changes: 203 additions & 0 deletions web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
1 change: 1 addition & 0 deletions web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
90 changes: 90 additions & 0 deletions web/src/utils/print.ts
Original file line number Diff line number Diff line change
@@ -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();
};