- 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
392 lines
8.8 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
|
|
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>`;
|
|
};
|