Files
nextcloud-notes-desktop-app/src/printExport.ts
drelich 6bc67a3118 Add interactive task lists and table insertion to markdown editor
- Enable task list checkbox toggling in preview mode with live content updates
- Add task list and table insertion buttons to InsertToolbar
- Implement smart block snippet insertion with automatic newline handling
- Add horizontal scroll wrapper for wide tables in preview
- Fix editor scroll position preservation during content updates
- Use useLayoutEffect to prevent scroll jumps when textarea auto-resizes
- Update task list styling
2026-04-06 16:15:43 +02:00

392 lines
8.8 KiB
TypeScript

export interface PrintExportPayload {
fileName: string;
title: string;
html: string;
previewFont: string;
previewFontSize: number;
previewFontFaceCss?: string;
}
export const PRINT_EXPORT_QUERY_PARAM = 'printJob';
const PRINT_DOCUMENT_CSP = [
"default-src 'none'",
"style-src 'unsafe-inline'",
"img-src data: blob: https: http:",
"font-src data:",
"object-src 'none'",
"base-uri 'none'",
].join('; ');
export const getNoteTitleFromContent = (content: string) => {
const firstLine = content
.split('\n')
.map((line) => line.trim())
.find((line) => line.length > 0);
return (firstLine || 'Untitled').replace(/^#+\s*/, '').trim();
};
export const sanitizeFileName = (name: string) =>
name
.replace(/[\\/:*?"<>|]/g, '-')
.replace(/\s+/g, ' ')
.trim() || 'note';
const escapeHtml = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const escapeFontFamily = (value: string) =>
value.replace(/["\\]/g, '\\$&');
interface PrintFontAsset {
fileName: string;
fontStyle: 'normal' | 'italic';
fontWeight: string;
}
const PRINT_FONT_ASSETS: Record<string, PrintFontAsset[]> = {
Merriweather: [
{
fileName: 'Merriweather-VariableFont_opsz,wdth,wght.ttf',
fontStyle: 'normal',
fontWeight: '300 900',
},
{
fileName: 'Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf',
fontStyle: 'italic',
fontWeight: '300 900',
},
],
'Crimson Pro': [
{
fileName: 'CrimsonPro-VariableFont_wght.ttf',
fontStyle: 'normal',
fontWeight: '200 900',
},
{
fileName: 'CrimsonPro-Italic-VariableFont_wght.ttf',
fontStyle: 'italic',
fontWeight: '200 900',
},
],
'Roboto Serif': [
{
fileName: 'RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf',
fontStyle: 'normal',
fontWeight: '100 900',
},
{
fileName: 'RobotoSerif-Italic-VariableFont_GRAD,opsz,wdth,wght.ttf',
fontStyle: 'italic',
fontWeight: '100 900',
},
],
Average: [
{
fileName: 'Average-Regular.ttf',
fontStyle: 'normal',
fontWeight: '400',
},
],
};
const fontDataUrlCache = new Map<string, Promise<string>>();
const blobToDataUrl = (blob: Blob) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error ?? new Error('Failed to read font file.'));
reader.readAsDataURL(blob);
});
const getBundledFontUrl = (fileName: string) =>
new URL(`./fonts/${fileName}`, window.location.href).toString();
const loadBundledFontDataUrl = async (fileName: string) => {
const cached = fontDataUrlCache.get(fileName);
if (cached) {
return cached;
}
const pending = (async () => {
const response = await fetch(getBundledFontUrl(fileName));
if (!response.ok) {
throw new Error(`Failed to load bundled font ${fileName}: ${response.status}`);
}
return blobToDataUrl(await response.blob());
})();
fontDataUrlCache.set(fileName, pending);
return pending;
};
export const loadPrintFontFaceCss = async (fontFamily: string) => {
const fontAssets = PRINT_FONT_ASSETS[fontFamily];
if (!fontAssets) {
return '';
}
const rules = await Promise.all(
fontAssets.map(async ({ fileName, fontStyle, fontWeight }) => {
try {
const dataUrl = await loadBundledFontDataUrl(fileName);
return `@font-face {
font-family: "${escapeFontFamily(fontFamily)}";
font-style: ${fontStyle};
font-weight: ${fontWeight};
font-display: swap;
src: url("${dataUrl}") format("truetype");
}`;
} catch (error) {
console.error(`Failed to embed preview font "${fontFamily}" from ${fileName}:`, error);
return '';
}
})
);
return rules.filter(Boolean).join('\n');
};
export const buildPrintDocument = (payload: PrintExportPayload) => {
const fontFamily = `"${escapeFontFamily(payload.previewFont)}", Georgia, serif`;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Content-Security-Policy" content="${PRINT_DOCUMENT_CSP}" />
<title>${escapeHtml(payload.fileName)}</title>
<style>
${payload.previewFontFaceCss ?? ''}
:root {
color-scheme: light;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: #ffffff;
color: #0f172a;
}
body {
font-family: ${fontFamily};
font-size: ${payload.previewFontSize}px;
line-height: 1.7;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
@page {
size: A4;
margin: 18mm 16mm 18mm 16mm;
}
article {
color: #0f172a;
}
.print-note h1,
.print-note h2,
.print-note h3 {
color: #020617;
font-weight: 700;
page-break-after: avoid;
}
.print-note h1 {
font-size: 2em;
line-height: 1.15;
margin: 0 0 1.35em;
letter-spacing: -0.015em;
text-align: center;
}
.print-note h2 {
font-size: 1.55em;
line-height: 1.2;
margin: 1.25em 0 0.45em;
}
.print-note h3 {
font-size: 1.25em;
line-height: 1.3;
margin: 1.1em 0 0.4em;
}
.print-note p {
margin: 0 0 0.9em;
}
.print-note ul,
.print-note ol {
margin: 0.75em 0 1em;
padding-left: 1.7em;
list-style-position: outside;
}
.print-note ul {
list-style-type: disc;
}
.print-note ol {
list-style-type: decimal;
}
.print-note li {
margin: 0.28em 0;
padding-left: 0.18em;
}
.print-note li > p {
margin: 0;
}
.print-note li::marker {
color: #334155;
}
.print-note ul:has(> li > input[type="checkbox"]) {
list-style: none;
padding-left: 0;
}
.print-note li:has(> input[type="checkbox"]) {
list-style: none;
display: flex;
align-items: flex-start;
gap: 0.55em;
padding-left: 0;
}
.print-note li:has(> input[type="checkbox"])::marker {
content: '';
}
.print-note li > input[type="checkbox"] {
flex-shrink: 0;
margin: 0.3em 0 0;
}
.print-note blockquote {
margin: 1.15em 0;
padding-left: 1em;
border-left: 3px solid #cbd5e1;
color: #475569;
font-style: italic;
}
.print-note blockquote > :first-child {
margin-top: 0;
}
.print-note blockquote > :last-child {
margin-bottom: 0;
}
.print-note pre {
margin: 1.15em 0 1.3em;
padding: 0.95em 1.05em;
overflow-x: auto;
border: 1px solid #dbe4f0;
border-radius: 12px;
background: #f5f7fb;
color: #0f172a;
page-break-inside: avoid;
}
.print-note pre code {
background: transparent;
border: 0;
padding: 0;
border-radius: 0;
font-size: 0.92em;
color: inherit;
}
.print-note code {
font-family: "SFMono-Regular", "SF Mono", "JetBrains Mono", "Fira Code", "Source Code Pro", Menlo, Consolas, monospace;
font-size: 0.92em;
padding: 0.08em 0.38em;
border: 1px solid #dbe4f0;
border-radius: 0.42em;
background: #f5f7fb;
color: #0f172a;
}
.print-note a {
color: #2563eb;
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.14em;
}
.print-note strong {
font-weight: 700;
}
.print-note em {
font-style: italic;
}
.print-note del {
text-decoration: line-through;
}
.print-note hr {
border: 0;
border-top: 1px solid #cbd5e1;
margin: 1.6em 0;
}
.print-note img {
display: block;
max-width: 100%;
height: auto;
margin: 1.2em auto;
border-radius: 12px;
page-break-inside: avoid;
}
.print-note table {
width: 100%;
border-collapse: collapse;
margin: 1em 0 1.2em;
font-size: 0.95em;
}
.print-note th,
.print-note td {
border: 1px solid #dbe4f0;
padding: 0.5em 0.65em;
text-align: left;
vertical-align: top;
}
.print-note th {
background: #f8fafc;
font-weight: 600;
}
</style>
</head>
<body>
<article class="print-note">${payload.html}</article>
</body>
</html>`;
};