fix(web): match exported PDF styling to the note

Print preview used a separate navy and emerald palette. It now follows the editor's light prose, including account type size, line height, palette accents, custom CSS, and the note font. Saving the preview downloads a PDF with the heading outline.
This commit is contained in:
tianma-if committed 2026-10-22 12:50:47 +08:00
1 parent 5c77cfcbf1
commit 573b5a6a41
23 files changed
+837 -154

No files matched your search

+8
View File
@@ -30,6 +30,7 @@ import {
mountedInstallerCandidates,
} from "./installation-location.mjs";
import { userDataDirectoryFromArguments } from "./user-data-directory.mjs";
import { PRINT_PDF_OPTIONS } from "./print-pdf.mjs";
import { isAllowedAppNavigation, isAllowedPrintPreviewUrl, isResourceDownloadUrl } from "./window-open-policy.mjs";
import { showWindow } from "./window-visibility.mjs";
import { globalShortcutAccelerator, normalizeGlobalShortcutBinding, replaceGlobalShortcut, toggleMainWindow } from "./global-shortcut.mjs";
@@ -1710,6 +1711,13 @@ const startApplication = async () => {
ipcMain.on("desktop:local-data-reset-available-sync", (event) => {
event.returnValue = process.platform === "darwin" && app.isPackaged && !requestedUserDataDirectory;
});
ipcMain.handle("desktop:print-pdf", async (event) => {
const appUrl = mainWindow?.webContents.getURL();
if (!appUrl || !isAllowedPrintPreviewUrl(event.sender.getURL(), appUrl)) {
throw new Error("Only the note print preview can save a PDF");
}
return event.sender.printToPDF(PRINT_PDF_OPTIONS);
});
ipcMain.handle("desktop:sidecar-request", async (_event, method, params) => {
if (!sidecar) throw new Error("EdgeEver sidecar is unavailable");
const result = await sidecar.request(method, params);
+9
View File
@@ -0,0 +1,9 @@
export const PRINT_PDF_OPTIONS = {
printBackground: true,
preferCSSPageSize: true,
displayHeaderFooter: false,
// Outline entries are ignored unless the PDF is also tagged.
generateDocumentOutline: true,
generateTaggedPDF: true,
margins: { top: 0, bottom: 0, left: 0, right: 0 },
};
+26
View File
@@ -0,0 +1,26 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "bun:test";
import { PRINT_PDF_OPTIONS } from "./print-pdf.mjs";
const read = (url) => readFileSync(new URL(url, import.meta.url), "utf8");
describe("desktop note PDF", () => {
test("asks Chromium for a heading outline in the viewer sidebar", () => {
expect(PRINT_PDF_OPTIONS).toMatchObject({
generateDocumentOutline: true,
generateTaggedPDF: true,
preferCSSPageSize: true,
displayHeaderFooter: false,
});
});
test("the print window can only save its own PDF", () => {
const preload = read("../preload/print.cjs");
const main = read("./index.mjs");
expect(preload).toContain('ipcRenderer.invoke("desktop:print-pdf")');
expect(preload).not.toContain("edgeeverDesktop");
expect(main).toContain('ipcMain.handle("desktop:print-pdf"');
expect(main).toContain("isAllowedPrintPreviewUrl");
expect(main).toContain("PRINT_PDF_OPTIONS");
});
});
+21 -1
View File
@@ -1 +1,21 @@
// Print windows deliberately expose no desktop bridge.
const { contextBridge, ipcRenderer } = require("electron");
const normalizeIpcBytes = (value) => {
if (value instanceof Uint8Array) {
const copy = new Uint8Array(value.byteLength);
copy.set(value);
return copy;
}
if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0));
if (ArrayBuffer.isView(value)) {
return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength));
}
if (Array.isArray(value)) return Uint8Array.from(value);
if (value && typeof value === "object" && value.type === "Buffer") return normalizeIpcBytes(value.data);
return new Uint8Array();
};
// The print window exposes only PDF saving. It does not get the desktop bridge.
contextBridge.exposeInMainWorld("edgeeverPrint", Object.freeze({
savePdf: () => ipcRenderer.invoke("desktop:print-pdf").then(normalizeIpcBytes),
}));
+1
View File
@@ -43,6 +43,7 @@
"motion": "^12.43.0",
"nanoid": "^6.0.1",
"pagedjs": "^0.4.3",
"pdf-lib": "1.17.1",
"streamdown": "^2.6.0",
"use-stick-to-bottom": "^1.1.6"
}
+1
View File
@@ -2838,6 +2838,7 @@ const RichEditorPane = ({
hydratedEditorMemoId,
markdownSource,
memo,
noteProse,
notebookName: notebookOptions.find((notebook) => notebook.id === memo?.notebookId)?.name ?? "",
onDocumentActionConsumed,
onSaveAsTemplate,
@@ -1,8 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Editor } from "@tiptap/react";
import { docToMarkdown, markdownToDoc, type MemoDetail, type TiptapDoc } from "@edgeever/shared";
import { docToMarkdown, markdownToDoc, type MemoDetail, type ResolvedNoteProse, type TiptapDoc } from "@edgeever/shared";
import { useMermaidTheme } from "@/components/ThemeProvider";
import type { MemoDocumentActionRequest } from "@/lib/app-helpers";
import { notePrintAppearanceFromProse } from "@/lib/note-print-appearance";
import { buildSingleNoteMarkdown, downloadMarkdownFile } from "@/lib/note-markdown-export";
import { downloadNoteHtmlFile, getHtmlImageEmbedNoticeKind } from "@/lib/note-html-export";
import { NOTE_HTML_FULL_STYLES } from "@/lib/note-html-export-assets";
@@ -25,6 +27,7 @@ export const useEditorDocumentActions = ({
hydratedEditorMemoId,
markdownSource,
memo,
noteProse,
notebookName,
onDocumentActionConsumed,
onSaveAsTemplate,
@@ -41,6 +44,7 @@ export const useEditorDocumentActions = ({
hydratedEditorMemoId: string | null;
markdownSource: string;
memo: MemoDetail | null;
noteProse: ResolvedNoteProse;
notebookName: string;
onDocumentActionConsumed?: (requestId: number) => void;
onSaveAsTemplate: (memo: MemoDetail, name: string) => Promise<void>;
@@ -50,6 +54,7 @@ export const useEditorDocumentActions = ({
useMobilePlainTextEditor: boolean;
}) => {
const { t, i18n } = useTranslation();
const { mermaidTheme } = useMermaidTheme();
const [shareOpen, setShareOpen] = useState(false);
const [imageShareOpen, setImageShareOpen] = useState(false);
const [imageShareSource, setImageShareSource] = useState<ShareNoteImageSource | null>(null);
@@ -119,6 +124,7 @@ export const useEditorDocumentActions = ({
updatedAt: formatDateTime(memo.updatedAt),
html: serializeNoteDocumentForPrint(editor, content.document),
language: i18n.resolvedLanguage ?? i18n.language,
appearance: notePrintAppearanceFromProse(noteProse, mermaidTheme),
labels: {
close: t("editor.pdfExport.close"),
error: t("editor.pdfExport.error"),
@@ -134,7 +140,7 @@ export const useEditorDocumentActions = ({
if (!opened) {
window.alert(t("editor.pdfExport.popupBlocked"));
}
}, [editor, i18n.language, i18n.resolvedLanguage, memo, notebookName, resolveExportContent, t, tagsText, title]);
}, [editor, i18n.language, i18n.resolvedLanguage, memo, mermaidTheme, noteProse, notebookName, resolveExportContent, t, tagsText, title]);
const handleExportMarkdown = useCallback(() => {
const content = resolveExportContent();
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test";
import { resolveNoteProse } from "@edgeever/shared";
import { notePrintAppearanceCss, notePrintAppearanceFromProse } from "./note-print-appearance.ts";
describe("note print appearance", () => {
test("keeps native prose on the editor neutrals and passes the account type size", () => {
const appearance = notePrintAppearanceFromProse(resolveNoteProse({
fontSize: 18,
lineHeight: 1.5,
palette: "native",
customCss: "",
}), "github-light", { choice: "system", customFamily: "" });
const css = notePrintAppearanceCss(appearance);
expect(appearance.mermaidTheme).toBe("github-light");
expect(appearance.fontFamily).toBeNull();
expect(css).toContain("--editor-body-font-size: 18px");
expect(css).toContain("--editor-body-line-height: 1.5");
expect(css).not.toContain("color: #");
});
test("tints links, bold, and checked boxes with the account palette", () => {
const css = notePrintAppearanceCss(notePrintAppearanceFromProse(resolveNoteProse({
palette: "teal",
customCss: "strong { color: #112233 }",
}), "not-a-theme", { choice: "system", customFamily: "" }));
expect(css).toContain("color: #14796E");
expect(css).toContain("color: #20AD9D");
expect(css).toContain("accent-color: #20AD9D");
expect(css).toContain(".edgeever-editor .ProseMirror strong { color: #112233; }");
expect(css).not.toContain("not-a-theme");
});
});
+82
View File
@@ -0,0 +1,82 @@
import {
NOTE_PROSE_PALETTES,
noteProseCssVariables,
type NoteProsePaletteChoice,
type ResolvedNoteProse,
} from "@edgeever/shared";
import { isRemoteNoteImageFont } from "@edgeever/shared/font-catalog-data";
import { LEGACY_BODY_FONTS } from "@edgeever/shared/font-defaults";
import type { BodyFontId } from "@edgeever/shared/font-loader";
import type { MermaidThemeName } from "@/components/ThemeProvider";
import { MERMAID_THEME_NAMES } from "@/components/ThemeProvider";
import { sanitizeAndScopeCss } from "@/lib/css-sandbox";
import {
readEditorBodyFontPreference,
resolveEditorBodyFontStack,
type EditorBodyFontPreference,
} from "@/lib/editor-body-font";
export type NotePrintAppearance = {
palette: NoteProsePaletteChoice;
cssVariables: Record<string, string>;
customCss: string;
fontFamily: string | null;
fontId: BodyFontId | null;
mermaidTheme: MermaidThemeName;
};
const CSS_VARIABLE_NAME = /^--[a-z0-9-]+$/i;
const CSS_DECLARATION_VALUE = /^[^;{}]+$/;
const loadableBodyFontId = (choice: string): BodyFontId | null => {
if (choice === "system" || choice === "custom") return null;
if (isRemoteNoteImageFont(choice) || Object.hasOwn(LEGACY_BODY_FONTS, choice)) return choice as BodyFontId;
return null;
};
export const notePrintAppearanceFromProse = (
prose: ResolvedNoteProse,
mermaidTheme: string,
font: EditorBodyFontPreference = readEditorBodyFontPreference(),
): NotePrintAppearance => ({
palette: prose.palette,
cssVariables: noteProseCssVariables(prose),
customCss: sanitizeAndScopeCss(prose.customCss),
fontFamily: resolveEditorBodyFontStack(font) ?? null,
fontId: loadableBodyFontId(font.choice),
mermaidTheme: (MERMAID_THEME_NAMES as readonly string[]).includes(mermaidTheme)
? mermaidTheme as MermaidThemeName
: "zinc-light",
});
const paletteRules = (palette: NoteProsePaletteChoice) => {
if (palette === "native") return "";
const colors = NOTE_PROSE_PALETTES[palette];
if (!colors) return "";
return [
".edgeever-print-content a:not(.edgeever-attachment-link):not([href*=\"/api/v1/resources/\"]) {",
` color: ${colors.link};`,
` text-decoration-color: color-mix(in srgb, ${colors.link} 45%, transparent);`,
"}",
".edgeever-print-content strong,",
".edgeever-print-content b {",
` color: ${colors.accent};`,
"}",
".edgeever-print-content ul[data-type=\"taskList\"] li[data-checked] > label input {",
` accent-color: ${colors.accent};`,
"}",
].join("\n");
};
/** Account prose settings for the print page. Values come from the note palette, not from the message text. */
export const notePrintAppearanceCss = (appearance: NotePrintAppearance) => {
const declarations = Object.entries(appearance.cssVariables)
.filter(([name, value]) => CSS_VARIABLE_NAME.test(name) && CSS_DECLARATION_VALUE.test(value))
.map(([name, value]) => `${name}: ${value}`);
if (appearance.fontFamily && CSS_DECLARATION_VALUE.test(appearance.fontFamily)) {
declarations.push(`--editor-body-font-family: ${appearance.fontFamily}`);
}
return [`:root { ${declarations.join("; ")}; }`, paletteRules(appearance.palette), appearance.customCss]
.filter(Boolean)
.join("\n");
};
@@ -0,0 +1,75 @@
import { describe, expect, test } from "bun:test";
import { parseHTML } from "linkedom";
import { PDFDocument, PDFHexString, PDFName } from "pdf-lib";
import { addPdfOutline, collectPrintOutline } from "./note-print-outline.ts";
const rect = (top, height) => () => ({
top, height, left: 0, width: 100, right: 100, bottom: top + height, x: 0, y: top, toJSON() { return {}; },
});
const outlineTitles = (pdf) => {
const titles = [];
const seen = new Set();
const visit = (ref) => {
if (!ref || seen.has(ref.toString())) return;
seen.add(ref.toString());
const item = pdf.context.lookup(ref);
const title = item.get?.(PDFName.of("Title"));
if (title instanceof PDFHexString) titles.push(title.decodeText());
visit(item.get?.(PDFName.of("First")));
visit(item.get?.(PDFName.of("Next")));
};
visit(pdf.catalog.get(PDFName.of("Outlines")) && pdf.context.lookup(pdf.catalog.get(PDFName.of("Outlines"))).get(PDFName.of("First")));
return titles;
};
describe("print PDF outline", () => {
test("nests body headings under the note title and records where they sit", () => {
const { document } = parseHTML("<!doctype html><body></body>");
document.body.innerHTML = `
<div class="pagedjs_page"><h1>欢迎使用 EdgeEver</h1><h2>概览</h2><h2>海报</h2><h3>排版定制</h3></div>
<div class="pagedjs_page"><h2 data-split-from="1">海报</h2><h2>表达力</h2></div>
`;
for (const page of document.querySelectorAll(".pagedjs_page")) page.getBoundingClientRect = rect(0, 1000);
const headings = [...document.querySelectorAll("h1, h2, h3")];
headings[0].getBoundingClientRect = rect(40, 30);
headings[1].getBoundingClientRect = rect(200, 20);
headings[2].getBoundingClientRect = rect(400, 20);
headings[3].getBoundingClientRect = rect(460, 20);
headings[5].getBoundingClientRect = rect(80, 20);
expect(collectPrintOutline(document)).toEqual([
{
title: "欢迎使用 EdgeEver",
pageIndex: 0,
topRatio: 0.04,
children: [
{ title: "概览", pageIndex: 0, topRatio: 0.2, children: [] },
{
title: "海报",
pageIndex: 0,
topRatio: 0.4,
children: [{ title: "排版定制", pageIndex: 0, topRatio: 0.46, children: [] }],
},
{ title: "表达力", pageIndex: 1, topRatio: 0.08, children: [] },
],
},
]);
});
test("writes expanded sidebar entries that point at the heading page", async () => {
const pdf = await PDFDocument.create();
pdf.addPage([100, 200]);
pdf.addPage([100, 200]);
addPdfOutline(pdf, [{
title: "欢迎使用 EdgeEver",
pageIndex: 0,
topRatio: 0,
children: [{ title: "表达力", pageIndex: 1, topRatio: 0.25, children: [] }],
}]);
const saved = await PDFDocument.load(await pdf.save());
expect(outlineTitles(saved)).toEqual(["欢迎使用 EdgeEver", "表达力"]);
const outlines = saved.context.lookup(saved.catalog.get(PDFName.of("Outlines")));
expect(outlines.get(PDFName.of("Count")).asNumber()).toBe(2);
});
});
+137
View File
@@ -0,0 +1,137 @@
import {
PDFDocument,
PDFHexString,
PDFName,
PDFNull,
PDFNumber,
type PDFDict,
type PDFRef,
} from "pdf-lib";
const HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6";
export type PrintOutlineEntry = {
title: string;
pageIndex: number;
/** Distance from the top of the page, as a fraction of the page height. */
topRatio: number;
children: PrintOutlineEntry[];
};
type FlatHeading = {
level: number;
title: string;
pageIndex: number;
topRatio: number;
};
const headingTitle = (element: HTMLElement) => element.textContent?.replace(/\s+/g, " ").trim().slice(0, 240) ?? "";
const topRatioWithin = (heading: HTMLElement, page: HTMLElement) => {
const pageRect = page.getBoundingClientRect();
const headingRect = heading.getBoundingClientRect();
if (pageRect.height <= 0) return 0;
const ratio = (headingRect.top - pageRect.top) / pageRect.height;
return Math.min(1, Math.max(0, ratio));
};
const nestHeadings = (headings: readonly FlatHeading[]): PrintOutlineEntry[] => {
const roots: PrintOutlineEntry[] = [];
const ancestors: Array<{ level: number; entry: PrintOutlineEntry }> = [];
for (const heading of headings) {
const entry: PrintOutlineEntry = {
title: heading.title,
pageIndex: heading.pageIndex,
topRatio: heading.topRatio,
children: [],
};
while (ancestors.length > 0 && ancestors[ancestors.length - 1]!.level >= heading.level) ancestors.pop();
const parent = ancestors[ancestors.length - 1];
if (parent) parent.entry.children.push(entry);
else roots.push(entry);
ancestors.push({ level: heading.level, entry });
}
return roots;
};
/** Headings in the paginated preview, in the same order as the editor outline. */
export const collectPrintOutline = (root: ParentNode): PrintOutlineEntry[] => {
const pages = [...root.querySelectorAll<HTMLElement>(".pagedjs_page")];
const headings: FlatHeading[] = [];
pages.forEach((page, pageIndex) => {
for (const heading of page.querySelectorAll<HTMLElement>(HEADING_SELECTOR)) {
if (heading.hasAttribute("data-split-from")) continue;
const title = headingTitle(heading);
if (!title) continue;
headings.push({
level: Number(heading.tagName.slice(1)) || 1,
title,
pageIndex,
topRatio: topRatioWithin(heading, page),
});
}
});
return nestHeadings(headings);
};
const descendantCount = (entry: PrintOutlineEntry): number =>
entry.children.reduce((sum, child) => sum + 1 + descendantCount(child), 0);
/** Writes the viewer's sidebar outline. Positive counts leave the entries expanded. */
export const addPdfOutline = (pdf: PDFDocument, entries: readonly PrintOutlineEntry[]) => {
if (entries.length === 0 || pdf.getPageCount() === 0) return;
const pages = pdf.getPages();
const context = pdf.context;
const outlines = context.obj({});
const outlinesRef = context.register(outlines);
const writeLevel = (nodes: readonly PrintOutlineEntry[], parentRef: PDFRef) => {
let previous: PDFRef | null = null;
let first: PDFRef | null = null;
let last: PDFRef | null = null;
for (const node of nodes) {
const item = context.obj({});
const ref = context.register(item);
first ??= ref;
last = ref;
const page = pages[Math.min(node.pageIndex, pages.length - 1)]!;
const y = page.getHeight() * (1 - node.topRatio);
item.set(PDFName.of("Title"), PDFHexString.fromText(node.title));
item.set(PDFName.of("Parent"), parentRef);
item.set(PDFName.of("Dest"), context.obj([
page.ref,
PDFName.of("XYZ"),
PDFNull,
PDFNumber.of(y),
PDFNull,
]));
const children = writeLevel(node.children, ref);
if (children) {
item.set(PDFName.of("First"), children.first);
item.set(PDFName.of("Last"), children.last);
item.set(PDFName.of("Count"), PDFNumber.of(descendantCount(node)));
}
if (previous) {
(context.lookup(previous) as PDFDict).set(PDFName.of("Next"), ref);
item.set(PDFName.of("Prev"), previous);
}
previous = ref;
}
return first && last ? { first, last } : null;
};
const top = writeLevel(entries, outlinesRef);
if (!top) return;
outlines.set(PDFName.of("Type"), PDFName.of("Outlines"));
outlines.set(PDFName.of("First"), top.first);
outlines.set(PDFName.of("Last"), top.last);
outlines.set(PDFName.of("Count"), PDFNumber.of(entries.reduce((sum, entry) => sum + 1 + descendantCount(entry), 0)));
pdf.catalog.set(PDFName.of("Outlines"), outlinesRef);
};
+81
View File
@@ -0,0 +1,81 @@
import { toCanvas } from "html-to-image";
import { PDFDocument } from "pdf-lib";
import { buildMarkdownFilename } from "@/lib/note-markdown-export";
import { addPdfOutline, collectPrintOutline } from "@/lib/note-print-outline";
const A4_WIDTH = 595.28;
const canvasToPng = (canvas: HTMLCanvasElement) => new Promise<Uint8Array>((resolve, reject) => {
canvas.toBlob(async (blob) => {
if (!blob) {
reject(new Error("Could not encode a PDF page"));
return;
}
resolve(new Uint8Array(await blob.arrayBuffer()));
}, "image/png");
});
export const buildPdfFilename = (title: string, fallback: string) =>
buildMarkdownFilename(title, fallback).replace(/\.md$/i, ".pdf");
export const downloadPdfBytes = (bytes: Uint8Array, filename: string) => {
const blob = new Blob([bytes], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.hidden = true;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
};
/** Browser export. Each preview page is captured, then the heading outline is attached. */
export const renderPagedPreviewPdf = async (root: ParentNode) => {
const pageElements = [...root.querySelectorAll<HTMLElement>(".pagedjs_page")];
if (pageElements.length === 0) throw new Error("Print preview has no pages");
const outline = collectPrintOutline(root);
const pdf = await PDFDocument.create();
for (const [index, pageElement] of pageElements.entries()) {
const previousShadow = pageElement.style.boxShadow;
const pageLabel = pageElement.querySelector(".pagedjs_margin-bottom-center .pagedjs_margin-content");
const previousLabel = pageLabel?.textContent ?? "";
pageElement.style.boxShadow = "none";
pageElement.classList.add("edgeever-print-pdf-capturing");
if (pageLabel) pageLabel.textContent = `${index + 1} / ${pageElements.length}`;
try {
const rect = pageElement.getBoundingClientRect();
const canvas = await toCanvas(pageElement, {
backgroundColor: "#ffffff",
cacheBust: false,
pixelRatio: 2,
skipAutoScale: true,
});
const image = await pdf.embedPng(await canvasToPng(canvas));
const width = A4_WIDTH;
const height = rect.width > 0 ? width * (rect.height / rect.width) : 841.89;
const page = pdf.addPage([width, height]);
page.drawImage(image, { x: 0, y: 0, width, height });
} finally {
pageElement.style.boxShadow = previousShadow;
pageElement.classList.remove("edgeever-print-pdf-capturing");
if (pageLabel) pageLabel.textContent = previousLabel;
}
}
addPdfOutline(pdf, outline);
return pdf.save();
};
type PrintBridge = {
savePdf?: () => Promise<Uint8Array>;
};
export const downloadNotePrintPdf = async (root: ParentNode, title: string, fallback: string) => {
const bridge = (window as Window & { edgeeverPrint?: PrintBridge }).edgeeverPrint;
const bytes = bridge?.savePdf ? await bridge.savePdf() : await renderPagedPreviewPdf(root);
if (bytes.byteLength === 0) throw new Error("PDF export returned an empty file");
downloadPdfBytes(bytes, buildPdfFilename(title, fallback));
};
+2
View File
@@ -3,6 +3,7 @@ import type { Editor } from "@tiptap/react";
import type { TiptapDoc } from "@edgeever/shared";
import { getAppPagePath, getMessageTargetOrigin } from "@/lib/app-page-path";
import { createClientUuid } from "@/lib/client-id";
import type { NotePrintAppearance } from "@/lib/note-print-appearance";
export const NOTE_PRINT_MESSAGE = "edgeever:note-print";
export const NOTE_PRINT_READY_MESSAGE = "edgeever:note-print-ready";
@@ -27,6 +28,7 @@ export type NotePrintPayload = {
html: string;
language: string;
labels: NotePrintLabels;
appearance: NotePrintAppearance;
};
type NotePrintReadyMessage = {
+44 -16
View File
@@ -8,14 +8,22 @@ import {
NOTE_PRINT_READY_MESSAGE,
type NotePrintPayload,
} from "@/lib/note-print";
import type { MermaidThemeName } from "@/components/ThemeProvider";
import { getMessageTargetOrigin } from "@/lib/app-page-path";
import { withEnvironmentTitlePrefix } from "@/lib/environment-title";
import { notePrintAppearanceCss } from "@/lib/note-print-appearance";
import { installEditorBodyFontFaces } from "@/lib/editor-body-font-faces";
import { downloadNotePrintPdf } from "@/lib/note-print-pdf";
import katexStyles from "katex/dist/katex.min.css?inline";
import githubHighlightStyles from "highlight.js/styles/github.css?inline";
import printStyles from "@/styles/note-print.css?inline";
import "@fontsource-variable/noto-sans-sc/wght.css";
import "@/styles/note-print-screen.css";
const PRINT_FONT_FAMILY = '"Noto Sans SC Variable", sans-serif';
const PRINT_FONT_FAMILY = '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", "Noto Sans SC Variable", sans-serif';
let printTitle = "EdgeEver";
let printPreparingLabel = "";
let printReadyLabel = "";
const toolbar = document.getElementById("print-toolbar");
const status = document.getElementById("print-status");
const hint = document.getElementById("print-hint");
@@ -30,8 +38,9 @@ const setText = (element: HTMLElement | null, value: string) => {
}
};
const renderMermaidBlocks = async (root: HTMLElement) => {
const renderMermaidBlocks = async (root: HTMLElement, theme: MermaidThemeName, font: string) => {
const blocks = Array.from(root.querySelectorAll<HTMLElement>("pre > code.language-mermaid"));
const palette = MERMAID_THEME_PALETTES[theme] ?? MERMAID_THEME_PALETTES["zinc-light"];
await Promise.all(blocks.map(async (code) => {
const source = code.textContent?.trim();
const container = code.closest("pre");
@@ -41,10 +50,10 @@ const renderMermaidBlocks = async (root: HTMLElement) => {
try {
const svg = scaleMermaidSvg(renderMermaidSVG(source, {
...THEMES["zinc-light"],
...MERMAID_THEME_PALETTES["zinc-light"],
...THEMES[theme] ?? THEMES["zinc-light"],
...palette,
transparent: true,
font: PRINT_FONT_FAMILY,
font,
padding: 24,
}));
const figure = document.createElement("figure");
@@ -77,17 +86,19 @@ const waitForImages = async (root: HTMLElement) => {
}));
};
const waitForPrintFonts = async (text: string) => {
await Promise.all([
document.fonts.load(`400 16px ${PRINT_FONT_FAMILY}`, text),
document.fonts.load(`700 16px ${PRINT_FONT_FAMILY}`, text),
]);
const waitForPrintFonts = async (text: string, fontId: NotePrintPayload["appearance"]["fontId"]) => {
installEditorBodyFontFaces();
if (fontId) {
const { loadBodyFont } = await import("@edgeever/shared/font-loader");
await loadBodyFont(fontId, text);
}
await document.fonts.ready;
};
const buildSource = (payload: NotePrintPayload) => {
const article = document.createElement("article");
article.className = "edgeever-print-document";
article.className = "edgeever-print-document edgeever-editor";
article.dataset.notePalette = payload.appearance.palette;
article.lang = payload.language;
const title = document.createElement("h1");
@@ -104,13 +115,16 @@ const buildSource = (payload: NotePrintPayload) => {
}
const content = document.createElement("div");
content.className = "edgeever-print-content";
content.className = "edgeever-print-content ProseMirror";
content.innerHTML = payload.html;
article.appendChild(content);
return article;
};
const renderPreview = async (payload: NotePrintPayload) => {
printTitle = payload.title;
printPreparingLabel = payload.labels.preparing;
printReadyLabel = payload.labels.ready;
document.documentElement.lang = payload.language;
document.title = withEnvironmentTitlePrefix(`${payload.title} · EdgeEver`, {
development: import.meta.env.DEV,
@@ -122,10 +136,11 @@ const renderPreview = async (payload: NotePrintPayload) => {
setText(printButton, payload.labels.print);
const source = buildSource(payload);
await renderMermaidBlocks(source);
const printFont = payload.appearance.fontFamily ?? PRINT_FONT_FAMILY;
await renderMermaidBlocks(source, payload.appearance.mermaidTheme, printFont);
highlightCodeBlocks(source);
await Promise.all([
waitForPrintFonts(source.textContent ?? ""),
waitForPrintFonts(source.textContent ?? "", payload.appearance.fontId),
waitForImages(source),
]);
@@ -136,7 +151,7 @@ const renderPreview = async (payload: NotePrintPayload) => {
const previewer = new Previewer();
await previewer.preview(
source,
[{ [window.location.href]: `${katexStyles}\n${printStyles}` }],
[{ [window.location.href]: `${katexStyles}\n${githubHighlightStyles}\n${printStyles}\n${notePrintAppearanceCss(payload.appearance)}` }],
preview
);
await document.fonts.ready;
@@ -150,7 +165,20 @@ const renderPreview = async (payload: NotePrintPayload) => {
};
closeButton?.addEventListener("click", () => window.close());
printButton?.addEventListener("click", () => window.print());
printButton?.addEventListener("click", () => {
if (!preview || !printButton || printButton.disabled) return;
printButton.disabled = true;
setText(status, printPreparingLabel);
void downloadNotePrintPdf(preview, printTitle, "EdgeEver").then(() => {
setText(status, printReadyLabel);
printButton.disabled = false;
}).catch((error) => {
console.error("Failed to save note PDF", error);
setText(status, printReadyLabel);
printButton.disabled = false;
window.print();
});
});
window.addEventListener("message", (event: MessageEvent<NotePrintPayload>) => {
if (
+5 -1
View File
@@ -92,7 +92,11 @@ body {
.pagedjs_page {
flex: 0 0 auto;
background: white;
font-family: "Noto Sans SC Variable", sans-serif;
color: #27272a;
font-family: var(--editor-body-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", "Noto Sans SC Variable", sans-serif);
font-size: var(--editor-body-font-size, 16px);
letter-spacing: -0.005em;
line-height: var(--editor-body-line-height, 1.65);
box-shadow: 0 14px 35px rgb(15 23 42 / 0.18);
}
+266 -124
View File
@@ -4,9 +4,9 @@
@bottom-center {
content: counter(page) " / " counter(pages);
color: #64748b;
font-family: "Noto Sans SC Variable", sans-serif;
font-size: 8.5pt;
color: #737373;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", "Noto Sans SC Variable", sans-serif;
font-size: 9pt;
}
}
@@ -14,35 +14,34 @@
box-sizing: border-box;
}
.edgeever-print-content .tiptap-mathematics-render[data-type="block-math"] {
max-width: 100%;
margin: 5mm 0;
overflow: hidden;
text-align: center;
}
.edgeever-print-document {
color: #172033;
font-family: "Noto Sans SC Variable", sans-serif;
font-size: 10.5pt;
line-height: 1.72;
--edgeever-system-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", "Noto Sans SC Variable", sans-serif;
color: #27272a;
font-family: var(--editor-body-font-family, var(--edgeever-system-font-family));
font-size: var(--editor-body-font-size, 16px);
font-feature-settings: "chws" 1;
letter-spacing: -0.005em;
line-height: var(--editor-body-line-height, 1.65);
overflow-wrap: anywhere;
}
.edgeever-print-title {
margin: 0 0 4mm;
color: #0f172a;
font-size: 24pt;
font-weight: 760;
line-height: 1.18;
margin: 0 0 0.75rem;
color: #1a1d21;
font-size: 1.5em;
font-weight: 650;
letter-spacing: -0.02em;
line-height: 1.28;
}
.edgeever-print-meta {
margin: 0 0 9mm;
border-bottom: 0.3mm solid #dbe4ea;
padding-bottom: 4mm;
color: #64748b;
font-size: 8.5pt;
margin: 0 0 1.5rem;
border-bottom: 1px solid #e5e5e5;
padding-bottom: 0.75rem;
color: #737373;
font-size: 0.8125rem;
letter-spacing: 0;
line-height: 1.45;
}
.edgeever-print-content > :first-child {
@@ -51,41 +50,95 @@
.edgeever-print-content h1,
.edgeever-print-content h2,
.edgeever-print-content h3 {
.edgeever-print-content h3,
.edgeever-print-content h4,
.edgeever-print-content h5,
.edgeever-print-content h6 {
break-after: avoid-page;
color: #0f172a;
line-height: 1.25;
font-weight: 600;
}
.edgeever-print-content h1 {
margin: 8mm 0 3.5mm;
font-size: 19pt;
margin: 0 0 0.85rem;
color: #1a1d21;
font-size: 1.5em;
font-weight: 650;
letter-spacing: -0.02em;
line-height: 1.28;
}
.edgeever-print-content h2 {
margin: 7mm 0 3mm;
font-size: 15pt;
margin: 1.4rem 0 0.6rem;
color: #27272a;
font-size: 1.25em;
letter-spacing: -0.015em;
line-height: 1.32;
}
.edgeever-print-content h3 {
margin: 6mm 0 2.5mm;
font-size: 12.5pt;
margin: 1.15rem 0 0.5rem;
color: #27272a;
font-size: 1.125em;
letter-spacing: -0.01em;
line-height: 1.38;
}
.edgeever-print-content h4 {
margin: 1.05rem 0 0.45rem;
color: #1e293b;
font-size: 1.0625em;
letter-spacing: -0.006em;
line-height: 1.4;
}
.edgeever-print-content h5 {
margin: 0.95rem 0 0.4rem;
color: #334155;
font-size: 1em;
line-height: 1.42;
}
.edgeever-print-content h6 {
margin: 0.85rem 0 0.35rem;
color: #334155;
font-size: 0.9375em;
line-height: 1.45;
}
.edgeever-print-content p {
margin: 0 0 3.5mm;
margin: 0 0 var(--editor-paragraph-spacing, 8px);
orphans: 3;
widows: 3;
}
.edgeever-print-content strong,
.edgeever-print-content b {
font-synthesis: weight style;
font-weight: 800;
}
.edgeever-print-content em,
.edgeever-print-content i {
font-synthesis: weight style;
font-style: italic;
}
.edgeever-print-content ul,
.edgeever-print-content ol {
margin: 0 0 4mm;
padding-left: 6mm;
margin: 0 0 1rem;
padding-left: 1.5rem;
}
.edgeever-print-content ul {
list-style-type: disc;
}
.edgeever-print-content ol {
list-style-type: decimal;
}
.edgeever-print-content li {
margin: 1mm 0;
margin: 0.25rem 0;
}
.edgeever-print-content ul[data-type="taskList"] {
@@ -96,7 +149,7 @@
.edgeever-print-content ul[data-type="taskList"] li[data-checked] {
display: flex;
align-items: flex-start;
gap: 2mm;
gap: 0.55rem;
break-inside: avoid-page;
}
@@ -104,12 +157,12 @@
display: inline-flex;
flex: 0 0 auto;
align-items: center;
margin-top: 0.8mm;
margin-top: 0.2rem;
}
.edgeever-print-content ul[data-type="taskList"] li[data-checked] > label input {
width: 4mm;
height: 4mm;
width: 1rem;
height: 1rem;
margin: 0;
accent-color: #1a1d21;
}
@@ -123,71 +176,152 @@
margin-bottom: 0;
}
.edgeever-print-content ul[data-type="taskList"] li[data-checked="true"] > div > p {
color: #737373;
text-decoration: line-through;
text-decoration-color: #a6a6a6;
}
.edgeever-print-content ul[data-type="taskList"] ul[data-type="taskList"] {
margin: 1mm 0 0;
padding-left: 6mm;
margin: 0.25rem 0 0;
padding-left: 1.5rem;
}
.edgeever-print-content blockquote {
break-inside: avoid-page;
margin: 4mm 0;
border-left: 1mm solid #10b981;
background: #f0fdf4;
padding: 3mm 4mm;
color: #334155;
margin: 0 0 1rem;
border: 0;
border-radius: 8px;
background: #f3f5f7;
padding: 0.75rem 1rem;
color: #3d4450;
}
.edgeever-print-content hr {
margin: 7mm 0;
margin: 1.5rem 0;
border: 0;
border-top: 0.3mm solid #cbd5e1;
border-top: 1px solid #1a1d21;
}
.edgeever-print-content hr.edgeever-merge-divider,
.edgeever-print-content hr[data-edgeever-merge-divider] {
margin: 8mm 0;
margin: 1.5rem 0;
border: 0;
border-top: 0.5mm solid #66ca80;
border-top: 2px solid #1a1d21;
background: transparent;
height: 0;
}
.edgeever-print-content a {
color: #047857;
color: #404040;
font-weight: 500;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, #404040 45%, transparent);
text-underline-offset: 2px;
}
.edgeever-print-content code {
border-radius: 1mm;
background: #f1f5f9;
padding: 0.2mm 0.8mm;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
.edgeever-print-content a.edgeever-note-link,
.edgeever-print-content a[href^="#memo="] {
display: inline-flex;
align-items: center;
gap: 0.2rem;
max-width: 100%;
padding: 0.03rem 0.32rem 0.05rem;
border: 1px solid #e5e5e5;
border-radius: 0.35rem;
background: #f5f5f5;
color: #404040;
font-size: 0.9em;
font-weight: 500;
text-decoration-color: color-mix(in srgb, #737373 55%, transparent);
vertical-align: baseline;
}
.edgeever-print-content a.edgeever-attachment-link,
.edgeever-print-content a[href*="/api/v1/resources/"] {
display: inline-flex;
align-items: center;
gap: 0.3rem;
max-width: 100%;
padding: 0.3rem 0.55rem;
border: 1px solid rgb(212 212 212 / 0.9);
border-radius: 0.45rem;
background: #fafafa;
color: #262626;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.2;
text-decoration: none;
}
.edgeever-print-content a.edgeever-attachment-link::before,
.edgeever-print-content a[href*="/api/v1/resources/"]::before {
content: "📎";
flex: 0 0 auto;
font-size: 0.8rem;
}
.edgeever-print-content :not(pre) > code {
border: 0;
border-radius: 4px;
background: #e9edf1;
box-shadow: inset 0 0 0 1px rgb(61 68 80 / 0.08);
padding: 0.08em 0.28em;
color: #3d4450;
font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: var(--editor-code-font-size, 14px);
font-weight: 450;
}
.edgeever-print-content pre,
.edgeever-print-content .edgeever-code-source {
break-inside: avoid-page;
max-width: 100%;
margin: 4mm 0;
border: 0.3mm solid #dbe4ea;
border-radius: 2mm;
background: #f8fafc;
padding: 3.5mm 4mm;
color: #1e293b;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 8.5pt;
line-height: 1.55;
margin: 0 0 1rem;
border: 1px solid rgb(212 212 212 / 0.6);
border-radius: 8px;
background: rgb(250 250 250 / 0.75);
padding: 0.9rem 1rem;
color: #24292e;
box-shadow: 0 1px 2px rgb(24 24 24 / 0.03);
font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: var(--editor-code-font-size, 14px);
font-weight: 550;
line-height: 1.6;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.edgeever-print-content pre code {
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
padding: 0;
color: inherit;
font-size: inherit;
font-weight: inherit;
}
.edgeever-print-content .tiptap-mathematics-render[data-type="block-math"] {
max-width: 100%;
margin: 1rem 0;
overflow: hidden;
text-align: center;
}
.edgeever-print-content img {
display: block;
width: auto;
max-width: 100%;
height: auto;
max-height: 220mm;
margin: 1rem 0;
border: 1px solid #b8dfd0;
border-radius: 8px;
object-fit: contain;
}
.edgeever-print-content img,
.edgeever-print-mermaid svg {
display: block;
width: auto;
@@ -201,14 +335,14 @@
.edgeever-print-mermaid {
break-inside: avoid-page;
max-width: 100%;
margin: 5mm auto;
margin: 1rem auto;
}
.edgeever-print-content [data-edgeever-image-gallery] {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 3mm;
margin: 5mm 0;
gap: 0.65rem;
margin: 1rem 0;
break-inside: avoid-page;
}
@@ -222,10 +356,9 @@
.edgeever-print-content [data-edgeever-image-gallery] > img {
width: 100%;
height: 52mm;
height: 13rem;
max-height: none;
margin: 0;
border-radius: 2mm;
object-fit: cover;
}
@@ -233,14 +366,31 @@
margin: 0 auto;
}
.edgeever-print-mermaid text {
font-family: var(--editor-body-font-family, var(--edgeever-system-font-family));
}
.edgeever-print-content .tableWrapper {
max-width: 100%;
margin: 1.25rem 0;
overflow: hidden;
border: 1px solid rgb(212 212 212 / 0.7);
border-radius: 6px;
background: #fff;
}
.edgeever-print-content table {
width: 100%;
margin: 5mm 0;
border-collapse: collapse;
font-size: 8.5pt;
margin: 1.25rem 0;
border-collapse: separate;
border-spacing: 0;
line-height: 1.45;
}
.edgeever-print-content .tableWrapper table {
margin: 0;
}
.edgeever-print-content thead {
display: table-header-group;
}
@@ -251,20 +401,46 @@
.edgeever-print-content th,
.edgeever-print-content td {
border: 0.3mm solid #cbd5e1;
padding: 2mm 2.5mm;
border: 0;
border-right: 1px solid rgb(229 229 229 / 0.8);
border-bottom: 1px solid rgb(229 229 229 / 0.8);
padding: 0.48rem 0.8rem;
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
}
.edgeever-print-content th:last-child,
.edgeever-print-content td:last-child {
border-right: 0;
}
.edgeever-print-content tr:last-child td {
border-bottom: 0;
}
.edgeever-print-content th {
background: #f1f5f9;
font-weight: 700;
background: rgb(245 245 245 / 0.75);
color: #181818;
font-size: 0.84em;
font-weight: 600;
letter-spacing: -0.01em;
}
.edgeever-print-content tbody tr:nth-child(even) td {
background: rgb(250 250 250 / 0.5);
}
.edgeever-print-content th p,
.edgeever-print-content td p {
margin: 0;
letter-spacing: inherit;
line-height: inherit;
}
.edgeever-print-content details {
break-inside: avoid-page;
margin: 2mm 0;
margin: 0.15em 0;
padding: 0;
border: 0;
background: transparent;
@@ -282,23 +458,18 @@
.edgeever-print-content [data-edgeever-theme-block] {
break-inside: avoid-page;
margin: 5mm 0;
border: 0.3mm solid #a7f3d0;
border-left: 1mm solid #059669;
border-radius: 2mm;
background: #f0fdf4;
padding: 3.5mm 4mm;
margin: 1.25rem 0;
border: 1px solid #dbeee5;
border-radius: 6px;
background: #fbfefc;
padding: 0.8rem 1rem;
}
.edgeever-print-content [data-theme-block-kind="chapter"] {
border-width: 1mm 0 0;
border-radius: 0;
background: transparent;
}
.edgeever-print-content .tableWrapper {
max-width: 100%;
overflow: visible;
border-width: 0;
border-left: 4px solid #16a06e;
border-radius: 6px;
background: #f0f8f4;
}
.edgeever-print-content .edgeever-image-controls,
@@ -307,32 +478,3 @@
.edgeever-print-content .edgeever-mermaid-source {
display: none !important;
}
.hljs-comment,
.hljs-quote {
color: #64748b;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-literal {
color: #7c3aed;
}
.hljs-string,
.hljs-title,
.hljs-section {
color: #047857;
}
.hljs-number,
.hljs-symbol,
.hljs-bullet {
color: #b45309;
}
.hljs-built_in,
.hljs-type,
.hljs-attribute {
color: #0369a1;
}
+13
View File
@@ -9,5 +9,18 @@ describe("note print page margin", () => {
expect(css).not.toContain("@top-center");
expect(css).not.toContain("edgeever-note-title");
expect(css).not.toContain("string-set:");
expect(css).not.toContain("edgeever-print-toc");
});
test("uses the editor's neutral prose colors", () => {
expect(css).toContain("color: #27272a");
expect(css).toContain("background: #f3f5f7");
expect(css).toContain("color: #3d4450");
expect(css).toContain("border-top: 1px solid #1a1d21");
expect(css).toContain("font-weight: 800");
expect(css).not.toContain("#047857");
expect(css).not.toContain("#10b981");
expect(css).not.toContain("#172033");
expect(css).not.toContain("#0f172a");
});
});
+5
View File
@@ -255,6 +255,11 @@ interface DesktopRendererErrorDetails {
exitCode?: number;
}
interface EdgeEverPrintBridge {
savePdf: () => Promise<Uint8Array>;
}
interface Window {
edgeeverDesktop?: EdgeEverDesktopBridge;
edgeeverPrint?: EdgeEverPrintBridge;
}