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:
1 parent
5c77cfcbf1
commit
573b5a6a41
23 files changed
+837
-154
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Vendored
+5
@@ -255,6 +255,11 @@ interface DesktopRendererErrorDetails {
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
interface EdgeEverPrintBridge {
|
||||
savePdf: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
edgeeverDesktop?: EdgeEverDesktopBridge;
|
||||
edgeeverPrint?: EdgeEverPrintBridge;
|
||||
}
|
||||
Reference in new issue
Block a user