feat: 完善 Mermaid 配置与预览缩放
This commit is contained in:
@@ -11,9 +11,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@mermaid-js/layout-elk": "0.2.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.28",
|
||||
"mermaid": "^11.12.3",
|
||||
"mermaid": "11.16.0",
|
||||
"pagedjs": "^0.4.3",
|
||||
"pdfjs-dist": "6.1.200",
|
||||
"react": "^19.2.0",
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; connect-src 'self';"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<style>
|
||||
html[data-rendering="true"] body {
|
||||
visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
<title>分页预览</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+37
-2
@@ -11,9 +11,11 @@ import {
|
||||
} from "react";
|
||||
import {
|
||||
getPaperDimensionsMm,
|
||||
millimetersToCssPixels,
|
||||
type ExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
|
||||
import { PreviewZoomControl } from "./PreviewZoomControl";
|
||||
import {
|
||||
loadExportConfig,
|
||||
resetExportConfig,
|
||||
@@ -37,6 +39,10 @@ import {
|
||||
type PdfExportRequest
|
||||
} from "./pdf-export";
|
||||
import { getSyncedScrollTop } from "./scroll-sync";
|
||||
import {
|
||||
loadPreviewZoom,
|
||||
savePreviewZoom
|
||||
} from "./preview-zoom";
|
||||
|
||||
const PrecisePdfPreview = lazy(async () => {
|
||||
const module = await import("./PrecisePdfPreview");
|
||||
@@ -122,6 +128,7 @@ export function App() {
|
||||
const [exportingPdf, setExportingPdf] = useState(false);
|
||||
const [previewMode, setPreviewMode] =
|
||||
useState<PreviewMode>("quick");
|
||||
const [previewZoom, setPreviewZoom] = useState(loadPreviewZoom);
|
||||
const [pdfCache, setPdfCache] = useState<CachedPdfExport>();
|
||||
const [previewFrameReady, setPreviewFrameReady] = useState(false);
|
||||
const [previewPagination, setPreviewPagination] = useState({
|
||||
@@ -151,9 +158,20 @@ export function App() {
|
||||
exportConfig.paper.format,
|
||||
exportConfig.paper.orientation
|
||||
);
|
||||
const previewScale = previewZoom / 100;
|
||||
const naturalPaperWidth =
|
||||
millimetersToCssPixels(paperDimensions.width) +
|
||||
PREVIEW_SCROLLBAR_WIDTH_PX;
|
||||
const scaledPaperWidth = naturalPaperWidth * previewScale;
|
||||
const paperStyle = {
|
||||
width: `calc(${paperDimensions.width}mm + ${PREVIEW_SCROLLBAR_WIDTH_PX}px)`,
|
||||
minWidth: `calc(${paperDimensions.width}mm + ${PREVIEW_SCROLLBAR_WIDTH_PX}px)`
|
||||
width: `${scaledPaperWidth}px`,
|
||||
minWidth: `${scaledPaperWidth}px`
|
||||
} satisfies CSSProperties;
|
||||
const previewFrameStyle = {
|
||||
width: `${100 / previewScale}%`,
|
||||
height: `${100 / previewScale}%`,
|
||||
transform: `scale(${previewScale})`,
|
||||
transformOrigin: "top left"
|
||||
} satisfies CSSProperties;
|
||||
const previewPayload = useMemo<PagedPreviewPayload | undefined>(
|
||||
() =>
|
||||
@@ -236,6 +254,15 @@ export function App() {
|
||||
}
|
||||
}, [exportConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
savePreviewZoom(previewZoom);
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
synchronizeScroll("editor");
|
||||
updatePreviewCurrentPage();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [previewZoom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pdfCache && pdfCache.key !== pdfCacheKey) {
|
||||
setPdfCache(undefined);
|
||||
@@ -715,6 +742,12 @@ export function App() {
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
<div className="preview-toolbar">
|
||||
<PreviewZoomControl
|
||||
value={previewZoom}
|
||||
onChange={setPreviewZoom}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={previewScrollRef}
|
||||
className="preview-scroll"
|
||||
@@ -726,6 +759,7 @@ export function App() {
|
||||
<iframe
|
||||
ref={previewFrameRef}
|
||||
className="preview-frame"
|
||||
style={previewFrameStyle}
|
||||
title="文档内容预览"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
src="/preview-frame.html"
|
||||
@@ -750,6 +784,7 @@ export function App() {
|
||||
>
|
||||
<PrecisePdfPreview
|
||||
blob={cachedPdf.blob}
|
||||
zoomPercent={previewZoom}
|
||||
onPageCount={handlePrecisePageCount}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import {
|
||||
getPaperDimensionsMm,
|
||||
paperFormatLabels,
|
||||
supportedMermaidLayouts,
|
||||
supportedMermaidLooks,
|
||||
supportedMermaidThemes,
|
||||
supportedPaperFormats,
|
||||
type ExportConfig,
|
||||
type FooterConfig,
|
||||
type HeaderConfig
|
||||
type HeaderConfig,
|
||||
type MermaidExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -30,6 +34,48 @@ const pageNumberFormatLabels: Record<FooterConfig["format"], string> = {
|
||||
custom: "自定义模板"
|
||||
};
|
||||
|
||||
const mermaidLayoutLabels: Record<
|
||||
MermaidExportConfig["layout"],
|
||||
string
|
||||
> = {
|
||||
dagre: "Dagre",
|
||||
elk: "ELK"
|
||||
};
|
||||
|
||||
const mermaidThemeLabels: Record<
|
||||
MermaidExportConfig["theme"],
|
||||
string
|
||||
> = {
|
||||
default: "Default",
|
||||
base: "Base(可定制基础主题)",
|
||||
dark: "Dark",
|
||||
forest: "Forest",
|
||||
neutral: "Neutral",
|
||||
neo: "Neo",
|
||||
"neo-dark": "Neo Dark",
|
||||
redux: "Redux",
|
||||
"redux-dark": "Redux Dark",
|
||||
"redux-color": "Redux Color",
|
||||
"redux-dark-color": "Redux Dark Color"
|
||||
};
|
||||
|
||||
const mermaidLookLabels: Record<
|
||||
MermaidExportConfig["look"],
|
||||
string
|
||||
> = {
|
||||
classic: "Classic",
|
||||
handDrawn: "Hand-drawn",
|
||||
neo: "Neo"
|
||||
};
|
||||
|
||||
const mermaidFontFamilies = [
|
||||
"Segoe UI, Microsoft YaHei, sans-serif",
|
||||
"Microsoft YaHei, sans-serif",
|
||||
"Arial, Helvetica, sans-serif",
|
||||
"Georgia, Times New Roman, serif",
|
||||
"Consolas, Microsoft YaHei, monospace"
|
||||
] as const;
|
||||
|
||||
function millimeters(value: string) {
|
||||
return Number.parseFloat(value.replace(/mm$/i, ""));
|
||||
}
|
||||
@@ -99,6 +145,54 @@ function LengthInput({
|
||||
);
|
||||
}
|
||||
|
||||
function MermaidFontInput({
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
function commit() {
|
||||
const normalized = draft.trim();
|
||||
if (!normalized) {
|
||||
setDraft(value);
|
||||
return;
|
||||
}
|
||||
setDraft(normalized);
|
||||
onChange(normalized);
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="setting-field stacked">
|
||||
<span>图表字体</span>
|
||||
<input
|
||||
type="text"
|
||||
list="mermaid-font-family-options"
|
||||
maxLength={300}
|
||||
value={draft}
|
||||
onBlur={commit}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<datalist id="mermaid-font-family-options">
|
||||
{mermaidFontFamilies.map((fontFamily) => (
|
||||
<option key={fontFamily} value={fontFamily} />
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function updateHeaderSlot(
|
||||
header: HeaderConfig,
|
||||
slot: "left" | "center" | "right",
|
||||
@@ -193,6 +287,16 @@ export function ExportSettingsDrawer({
|
||||
});
|
||||
}
|
||||
|
||||
function updateMermaid(mermaid: Partial<MermaidExportConfig>) {
|
||||
onChange({
|
||||
...config,
|
||||
mermaid: {
|
||||
...config.mermaid,
|
||||
...mermaid
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-layer">
|
||||
<button
|
||||
@@ -307,6 +411,73 @@ export function ExportSettingsDrawer({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>Mermaid 图表</h3>
|
||||
<label className="setting-field">
|
||||
<span>布局算法</span>
|
||||
<select
|
||||
value={config.mermaid.layout}
|
||||
onChange={(event) =>
|
||||
updateMermaid({
|
||||
layout: event.target
|
||||
.value as MermaidExportConfig["layout"]
|
||||
})
|
||||
}
|
||||
>
|
||||
{supportedMermaidLayouts.map((layout) => (
|
||||
<option key={layout} value={layout}>
|
||||
{mermaidLayoutLabels[layout]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="setting-field">
|
||||
<span>图表主题</span>
|
||||
<select
|
||||
value={config.mermaid.theme}
|
||||
onChange={(event) =>
|
||||
updateMermaid({
|
||||
theme: event.target
|
||||
.value as MermaidExportConfig["theme"]
|
||||
})
|
||||
}
|
||||
>
|
||||
{supportedMermaidThemes.map((theme) => (
|
||||
<option key={theme} value={theme}>
|
||||
{mermaidThemeLabels[theme]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="setting-field">
|
||||
<span>图表外观</span>
|
||||
<select
|
||||
value={config.mermaid.look}
|
||||
onChange={(event) =>
|
||||
updateMermaid({
|
||||
look: event.target
|
||||
.value as MermaidExportConfig["look"]
|
||||
})
|
||||
}
|
||||
>
|
||||
{supportedMermaidLooks.map((look) => (
|
||||
<option key={look} value={look}>
|
||||
{mermaidLookLabels[look]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<MermaidFontInput
|
||||
value={config.mermaid.fontFamily}
|
||||
onChange={(fontFamily) => updateMermaid({ fontFamily })}
|
||||
/>
|
||||
<p className="setting-hint">
|
||||
单个 Mermaid 代码块中的 config frontmatter
|
||||
优先于这里的全局设置;style、classDef 和 themeVariables
|
||||
会继续按 Mermaid 原生语法生效。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="setting-section-title">
|
||||
<h3>页眉</h3>
|
||||
|
||||
@@ -32,6 +32,7 @@ interface PdfPageDescriptor {
|
||||
|
||||
export interface PrecisePdfPreviewProps {
|
||||
blob: Blob;
|
||||
zoomPercent: number;
|
||||
onPageCount?: (pageCount: number) => void;
|
||||
}
|
||||
|
||||
@@ -39,6 +40,7 @@ interface PrecisePdfPageProps {
|
||||
document: PDFDocumentProxy;
|
||||
descriptor: PdfPageDescriptor;
|
||||
renderQueue: PdfPageRenderQueue;
|
||||
zoomPercent: number;
|
||||
}
|
||||
|
||||
function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
|
||||
@@ -90,7 +92,8 @@ function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
|
||||
function PrecisePdfPage({
|
||||
document,
|
||||
descriptor,
|
||||
renderQueue
|
||||
renderQueue,
|
||||
zoomPercent
|
||||
}: PrecisePdfPageProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
@@ -103,10 +106,12 @@ function PrecisePdfPage({
|
||||
const nearViewport = useNearViewport(containerRef);
|
||||
const dimensions = getPdfPageCssDimensions(
|
||||
descriptor.width,
|
||||
descriptor.height
|
||||
descriptor.height,
|
||||
zoomPercent / 100
|
||||
);
|
||||
const pageStyle = {
|
||||
width: `${dimensions.width}px`,
|
||||
maxWidth: `${zoomPercent}%`,
|
||||
aspectRatio: `${descriptor.width} / ${descriptor.height}`
|
||||
} satisfies CSSProperties;
|
||||
|
||||
@@ -293,6 +298,7 @@ function PrecisePdfPage({
|
||||
|
||||
export function PrecisePdfPreview({
|
||||
blob,
|
||||
zoomPercent,
|
||||
onPageCount
|
||||
}: PrecisePdfPreviewProps) {
|
||||
const [document, setDocument] = useState<PDFDocumentProxy>();
|
||||
@@ -381,6 +387,7 @@ export function PrecisePdfPreview({
|
||||
document={document}
|
||||
descriptor={page}
|
||||
renderQueue={renderQueue}
|
||||
zoomPercent={zoomPercent}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
DEFAULT_PREVIEW_ZOOM,
|
||||
MAXIMUM_PREVIEW_ZOOM,
|
||||
MINIMUM_PREVIEW_ZOOM,
|
||||
normalizePreviewZoom,
|
||||
parsePreviewZoom,
|
||||
PREVIEW_ZOOM_SLIDER_STEP
|
||||
} from "./preview-zoom";
|
||||
|
||||
interface PreviewZoomControlProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export function PreviewZoomControl({
|
||||
value,
|
||||
onChange
|
||||
}: PreviewZoomControlProps) {
|
||||
const [draft, setDraft] = useState(String(value));
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(String(value));
|
||||
}, [value]);
|
||||
|
||||
function commitDraft() {
|
||||
const normalized = parsePreviewZoom(draft, value);
|
||||
setDraft(String(normalized));
|
||||
onChange(normalized);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="preview-zoom-control"
|
||||
role="group"
|
||||
aria-label="预览缩放"
|
||||
>
|
||||
<span>缩放</span>
|
||||
<input
|
||||
className="preview-zoom-slider"
|
||||
type="range"
|
||||
aria-label="预览缩放滑块"
|
||||
min={MINIMUM_PREVIEW_ZOOM}
|
||||
max={MAXIMUM_PREVIEW_ZOOM}
|
||||
step={PREVIEW_ZOOM_SLIDER_STEP}
|
||||
value={value}
|
||||
onChange={(event) =>
|
||||
onChange(normalizePreviewZoom(Number(event.target.value)))
|
||||
}
|
||||
/>
|
||||
<label className="preview-zoom-number">
|
||||
<span className="sr-only">预览缩放百分比</span>
|
||||
<input
|
||||
type="number"
|
||||
aria-label="预览缩放百分比"
|
||||
min={MINIMUM_PREVIEW_ZOOM}
|
||||
max={MAXIMUM_PREVIEW_ZOOM}
|
||||
step="1"
|
||||
value={draft}
|
||||
onBlur={commitDraft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>%</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="preview-zoom-reset"
|
||||
disabled={value === DEFAULT_PREVIEW_ZOOM}
|
||||
onClick={() => onChange(DEFAULT_PREVIEW_ZOOM)}
|
||||
>
|
||||
100%
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
type ExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v2";
|
||||
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v3";
|
||||
export const LEGACY_EXPORT_CONFIG_STORAGE_KEY =
|
||||
"md-to-pdf.export-config.v2";
|
||||
|
||||
type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
|
||||
|
||||
@@ -18,7 +20,19 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = exportConfigSchema.safeParse(JSON.parse(rawValue));
|
||||
const value: unknown = JSON.parse(rawValue);
|
||||
const migratedValue =
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"version" in value &&
|
||||
value.version === 2
|
||||
? {
|
||||
...value,
|
||||
version: defaultExportConfig.version,
|
||||
mermaid: structuredClone(defaultExportConfig.mermaid)
|
||||
}
|
||||
: value;
|
||||
const parsed = exportConfigSchema.safeParse(migratedValue);
|
||||
return parsed.success ? parsed.data : cloneDefaultExportConfig();
|
||||
} catch {
|
||||
return cloneDefaultExportConfig();
|
||||
@@ -28,7 +42,17 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
|
||||
export function loadExportConfig(
|
||||
storage: SettingsStorage = window.localStorage
|
||||
) {
|
||||
return parseStoredExportConfig(storage.getItem(EXPORT_CONFIG_STORAGE_KEY));
|
||||
const currentValue = storage.getItem(EXPORT_CONFIG_STORAGE_KEY);
|
||||
if (currentValue) {
|
||||
return parseStoredExportConfig(currentValue);
|
||||
}
|
||||
|
||||
const legacyValue = storage.getItem(LEGACY_EXPORT_CONFIG_STORAGE_KEY);
|
||||
const config = parseStoredExportConfig(legacyValue);
|
||||
if (legacyValue) {
|
||||
storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config));
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function saveExportConfig(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { MermaidExportConfig } from "@md-to-pdf/core";
|
||||
import type { MermaidConfig } from "mermaid";
|
||||
|
||||
export const MERMAID_SECURE_CONFIG_KEYS = [
|
||||
@@ -10,13 +11,16 @@ export const MERMAID_SECURE_CONFIG_KEYS = [
|
||||
"themeCSS"
|
||||
];
|
||||
|
||||
export function createMermaidSiteConfig(): MermaidConfig {
|
||||
export function createMermaidSiteConfig(
|
||||
config: MermaidExportConfig
|
||||
): MermaidConfig {
|
||||
return {
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
theme: "default",
|
||||
look: "classic",
|
||||
fontFamily: "Segoe UI, Microsoft YaHei, sans-serif",
|
||||
layout: config.layout,
|
||||
theme: config.theme,
|
||||
look: config.look,
|
||||
fontFamily: config.fontFamily,
|
||||
flowchart: {
|
||||
wrappingWidth: 320
|
||||
},
|
||||
|
||||
@@ -131,9 +131,12 @@ export class PagedDocumentRuntime {
|
||||
|
||||
private loadMermaid() {
|
||||
if (!this.mermaidPromise) {
|
||||
this.mermaidPromise = import("mermaid").then(
|
||||
({ default: mermaid }) => {
|
||||
mermaid.initialize(createMermaidSiteConfig());
|
||||
this.mermaidPromise = Promise.all([
|
||||
import("mermaid"),
|
||||
import("@mermaid-js/layout-elk")
|
||||
]).then(
|
||||
([{ default: mermaid }, { default: elkLayouts }]) => {
|
||||
mermaid.registerLayoutLoaders(elkLayouts);
|
||||
return mermaid;
|
||||
}
|
||||
);
|
||||
@@ -160,6 +163,9 @@ export class PagedDocumentRuntime {
|
||||
}
|
||||
|
||||
const mermaid = await this.loadMermaid();
|
||||
mermaid.initialize(
|
||||
createMermaidSiteConfig(payload.exportConfig.mermaid)
|
||||
);
|
||||
const outcomes = await renderMermaidDefinitions(
|
||||
nodes.map((node) => node.textContent ?? ""),
|
||||
async (definition) => {
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
} from "./paged-preview";
|
||||
import { resolvePagedRenderTarget } from "./paged-render-target";
|
||||
import { resolveMermaidOutputMode } from "./mermaid-static-image";
|
||||
import {
|
||||
beginPreviewFrameRender,
|
||||
completePreviewFrameRender
|
||||
} from "./preview-frame-visibility";
|
||||
|
||||
function getPreviewRoot() {
|
||||
const element = document.querySelector<HTMLElement>("#preview-root");
|
||||
@@ -37,27 +41,34 @@ async function renderPagedPreview(
|
||||
return;
|
||||
}
|
||||
|
||||
beginPreviewFrameRender(document.documentElement, requestId);
|
||||
post({
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "rendering",
|
||||
requestId
|
||||
});
|
||||
|
||||
const result = await runtime.render(payload, {
|
||||
target: renderTarget,
|
||||
mermaidOutput,
|
||||
shouldContinue: () => requestId === latestRequestId
|
||||
});
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runtime.render(payload, {
|
||||
target: renderTarget,
|
||||
mermaidOutput,
|
||||
shouldContinue: () => requestId === latestRequestId
|
||||
});
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
post({
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "rendered",
|
||||
requestId,
|
||||
...result
|
||||
});
|
||||
post({
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "rendered",
|
||||
requestId,
|
||||
...result
|
||||
});
|
||||
} finally {
|
||||
if (requestId === latestRequestId) {
|
||||
completePreviewFrameRender(document.documentElement, requestId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.__mdToPdfRender = async (
|
||||
|
||||
@@ -5,11 +5,12 @@ export const PDF_PAGE_CSS_SCALE =
|
||||
|
||||
export function getPdfPageCssDimensions(
|
||||
widthPoints: number,
|
||||
heightPoints: number
|
||||
heightPoints: number,
|
||||
displayScale = 1
|
||||
) {
|
||||
return {
|
||||
width: widthPoints * PDF_PAGE_CSS_SCALE,
|
||||
height: heightPoints * PDF_PAGE_CSS_SCALE
|
||||
width: widthPoints * PDF_PAGE_CSS_SCALE * displayScale,
|
||||
height: heightPoints * PDF_PAGE_CSS_SCALE * displayScale
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
interface PreviewFrameVisibilityTarget {
|
||||
dataset: DOMStringMap;
|
||||
}
|
||||
|
||||
export function beginPreviewFrameRender(
|
||||
target: PreviewFrameVisibilityTarget,
|
||||
requestId: number
|
||||
) {
|
||||
target.dataset.rendering = "true";
|
||||
target.dataset.renderingRequestId = String(requestId);
|
||||
}
|
||||
|
||||
export function completePreviewFrameRender(
|
||||
target: PreviewFrameVisibilityTarget,
|
||||
requestId: number
|
||||
) {
|
||||
if (target.dataset.renderingRequestId !== String(requestId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
delete target.dataset.rendering;
|
||||
delete target.dataset.renderingRequestId;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export const PREVIEW_ZOOM_STORAGE_KEY = "md-to-pdf.preview-zoom.v1";
|
||||
export const DEFAULT_PREVIEW_ZOOM = 100;
|
||||
export const MINIMUM_PREVIEW_ZOOM = 50;
|
||||
export const MAXIMUM_PREVIEW_ZOOM = 400;
|
||||
export const PREVIEW_ZOOM_SLIDER_STEP = 5;
|
||||
|
||||
type PreviewZoomStorage = Pick<Storage, "getItem" | "setItem">;
|
||||
|
||||
export function normalizePreviewZoom(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return DEFAULT_PREVIEW_ZOOM;
|
||||
}
|
||||
return Math.min(
|
||||
MAXIMUM_PREVIEW_ZOOM,
|
||||
Math.max(MINIMUM_PREVIEW_ZOOM, Math.round(value))
|
||||
);
|
||||
}
|
||||
|
||||
export function parsePreviewZoom(
|
||||
value: string | null,
|
||||
fallback = DEFAULT_PREVIEW_ZOOM
|
||||
) {
|
||||
if (!value?.trim()) {
|
||||
return normalizePreviewZoom(fallback);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed)
|
||||
? normalizePreviewZoom(parsed)
|
||||
: normalizePreviewZoom(fallback);
|
||||
}
|
||||
|
||||
export function loadPreviewZoom(
|
||||
storage: PreviewZoomStorage = window.localStorage
|
||||
) {
|
||||
try {
|
||||
return parsePreviewZoom(storage.getItem(PREVIEW_ZOOM_STORAGE_KEY));
|
||||
} catch {
|
||||
return DEFAULT_PREVIEW_ZOOM;
|
||||
}
|
||||
}
|
||||
|
||||
export function savePreviewZoom(
|
||||
value: number,
|
||||
storage: PreviewZoomStorage = window.localStorage
|
||||
) {
|
||||
try {
|
||||
storage.setItem(
|
||||
PREVIEW_ZOOM_STORAGE_KEY,
|
||||
String(normalizePreviewZoom(value))
|
||||
);
|
||||
} catch {
|
||||
// 预览缩放是非关键偏好,浏览器禁用缓存时保持当前会话值即可。
|
||||
}
|
||||
}
|
||||
+87
-2
@@ -176,6 +176,78 @@ h1 {
|
||||
max-width: 68%;
|
||||
}
|
||||
|
||||
.preview-toolbar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 6px 20px;
|
||||
border-bottom: 1px solid #d9dedb;
|
||||
background: rgb(255 255 255 / 58%);
|
||||
}
|
||||
|
||||
.preview-zoom-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: #5d7168;
|
||||
font-size: 0.7rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.preview-zoom-slider {
|
||||
width: 150px;
|
||||
accent-color: #4f8a6f;
|
||||
}
|
||||
|
||||
.preview-zoom-number {
|
||||
display: flex;
|
||||
width: 68px;
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
border: 1px solid #c9d4ce;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.preview-zoom-number:focus-within {
|
||||
border-color: #74a88f;
|
||||
box-shadow: 0 0 0 3px rgb(116 168 143 / 16%);
|
||||
}
|
||||
|
||||
.preview-zoom-number input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
padding: 0 2px 0 8px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: #356c55;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.preview-zoom-number > span:last-child {
|
||||
padding-right: 7px;
|
||||
color: #7a8580;
|
||||
}
|
||||
|
||||
.preview-zoom-reset {
|
||||
min-height: 30px;
|
||||
padding: 0 8px;
|
||||
border-color: #c9d4ce;
|
||||
background: #f8fbf9;
|
||||
color: #4f695e;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.preview-zoom-reset:disabled {
|
||||
color: #9ba49f;
|
||||
}
|
||||
|
||||
.preview-page-indicator {
|
||||
flex: none;
|
||||
color: #5d7168;
|
||||
@@ -319,7 +391,7 @@ textarea:focus {
|
||||
display: flex;
|
||||
min-width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
align-items: safe center;
|
||||
gap: 24px;
|
||||
padding: 34px 30px;
|
||||
}
|
||||
@@ -327,7 +399,6 @@ textarea:focus {
|
||||
.precise-pdf-page {
|
||||
position: relative;
|
||||
flex: none;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 48px rgb(37 49 43 / 16%);
|
||||
@@ -775,6 +846,20 @@ textarea:focus {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preview-toolbar {
|
||||
justify-content: center;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.preview-zoom-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-zoom-slider {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.theme-control select {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
@@ -2,24 +2,32 @@ import { describe, expect, it } from "vitest";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
EXPORT_CONFIG_STORAGE_KEY,
|
||||
LEGACY_EXPORT_CONFIG_STORAGE_KEY,
|
||||
loadExportConfig,
|
||||
parseStoredExportConfig,
|
||||
saveExportConfig
|
||||
} from "../src/export-settings";
|
||||
|
||||
function createMemoryStorage(initialValue: string | null = null) {
|
||||
let value = initialValue;
|
||||
function createMemoryStorage(
|
||||
initialValue: string | null = null,
|
||||
legacyValue: string | null = null
|
||||
) {
|
||||
const values = new Map<string, string>();
|
||||
if (initialValue) {
|
||||
values.set(EXPORT_CONFIG_STORAGE_KEY, initialValue);
|
||||
}
|
||||
if (legacyValue) {
|
||||
values.set(LEGACY_EXPORT_CONFIG_STORAGE_KEY, legacyValue);
|
||||
}
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return key === EXPORT_CONFIG_STORAGE_KEY ? value : null;
|
||||
return values.get(key) ?? null;
|
||||
},
|
||||
setItem(key: string, nextValue: string) {
|
||||
if (key === EXPORT_CONFIG_STORAGE_KEY) {
|
||||
value = nextValue;
|
||||
}
|
||||
values.set(key, nextValue);
|
||||
},
|
||||
read() {
|
||||
return value;
|
||||
return values.get(EXPORT_CONFIG_STORAGE_KEY) ?? null;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -44,6 +52,28 @@ describe("导出设置缓存", () => {
|
||||
).toEqual(defaultExportConfig);
|
||||
});
|
||||
|
||||
it("将 v2 缓存迁移为 v3 并保留已有设置", () => {
|
||||
const legacyConfig = {
|
||||
...defaultExportConfig,
|
||||
version: 2,
|
||||
mermaid: undefined,
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
format: "A5"
|
||||
}
|
||||
};
|
||||
const storage = createMemoryStorage(
|
||||
null,
|
||||
JSON.stringify(legacyConfig)
|
||||
);
|
||||
|
||||
const migrated = loadExportConfig(storage);
|
||||
expect(migrated.version).toBe(3);
|
||||
expect(migrated.paper.format).toBe("A5");
|
||||
expect(migrated.mermaid).toEqual(defaultExportConfig.mermaid);
|
||||
expect(storage.read()).toContain('"version":3');
|
||||
});
|
||||
|
||||
it("保存并恢复经过校验的配置", () => {
|
||||
const storage = createMemoryStorage();
|
||||
const config = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
createMermaidSiteConfig,
|
||||
MERMAID_SECURE_CONFIG_KEYS
|
||||
@@ -6,7 +7,7 @@ import {
|
||||
|
||||
describe("Mermaid 站点配置", () => {
|
||||
it("默认使用 default 主题和 classic 外观", () => {
|
||||
const config = createMermaidSiteConfig();
|
||||
const config = createMermaidSiteConfig(defaultExportConfig.mermaid);
|
||||
expect(config.theme).toBe("default");
|
||||
expect(config.look).toBe("classic");
|
||||
expect(config.securityLevel).toBe("strict");
|
||||
@@ -29,9 +30,25 @@ describe("Mermaid 站点配置", () => {
|
||||
});
|
||||
|
||||
it("每次创建独立的 secure 配置数组", () => {
|
||||
const first = createMermaidSiteConfig();
|
||||
const second = createMermaidSiteConfig();
|
||||
const first = createMermaidSiteConfig(defaultExportConfig.mermaid);
|
||||
const second = createMermaidSiteConfig(defaultExportConfig.mermaid);
|
||||
expect(first.secure).toEqual(second.secure);
|
||||
expect(first.secure).not.toBe(second.secure);
|
||||
});
|
||||
|
||||
it("应用导出配置中的布局、主题、外观和字体", () => {
|
||||
const config = createMermaidSiteConfig({
|
||||
layout: "elk",
|
||||
theme: "forest",
|
||||
look: "handDrawn",
|
||||
fontFamily: "Microsoft YaHei, sans-serif"
|
||||
});
|
||||
|
||||
expect(config).toMatchObject({
|
||||
layout: "elk",
|
||||
theme: "forest",
|
||||
look: "handDrawn",
|
||||
fontFamily: "Microsoft YaHei, sans-serif"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,15 @@ describe("精确 PDF 页面尺寸", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("按预览缩放比例调整 PDF 页面显示尺寸", () => {
|
||||
expect(
|
||||
getPdfPageCssDimensions(595.275591, 841.889764, 1.5)
|
||||
).toEqual({
|
||||
width: expect.closeTo(1190.551182, 5),
|
||||
height: expect.closeTo(1683.779528, 5)
|
||||
});
|
||||
});
|
||||
|
||||
it("结合显示宽度和设备像素比计算 Canvas 比例", () => {
|
||||
expect(getPdfCanvasRenderScale(600, 800, 2)).toBeCloseTo(
|
||||
8 / 3
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
beginPreviewFrameRender,
|
||||
completePreviewFrameRender
|
||||
} from "../src/preview-frame-visibility";
|
||||
|
||||
function createTarget() {
|
||||
return {
|
||||
dataset: {} as DOMStringMap
|
||||
};
|
||||
}
|
||||
|
||||
describe("分页 iframe 渲染可见性", () => {
|
||||
it("渲染期间隐藏,当前请求完成后恢复显示", () => {
|
||||
const target = createTarget();
|
||||
|
||||
beginPreviewFrameRender(target, 12);
|
||||
expect(target.dataset).toMatchObject({
|
||||
rendering: "true",
|
||||
renderingRequestId: "12"
|
||||
});
|
||||
|
||||
expect(completePreviewFrameRender(target, 12)).toBe(true);
|
||||
expect(target.dataset.rendering).toBeUndefined();
|
||||
expect(target.dataset.renderingRequestId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("旧请求完成时不会提前显示新请求的半成品", () => {
|
||||
const target = createTarget();
|
||||
|
||||
beginPreviewFrameRender(target, 12);
|
||||
beginPreviewFrameRender(target, 13);
|
||||
|
||||
expect(completePreviewFrameRender(target, 12)).toBe(false);
|
||||
expect(target.dataset).toMatchObject({
|
||||
rendering: "true",
|
||||
renderingRequestId: "13"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_PREVIEW_ZOOM,
|
||||
MAXIMUM_PREVIEW_ZOOM,
|
||||
MINIMUM_PREVIEW_ZOOM,
|
||||
PREVIEW_ZOOM_STORAGE_KEY,
|
||||
loadPreviewZoom,
|
||||
normalizePreviewZoom,
|
||||
parsePreviewZoom,
|
||||
savePreviewZoom
|
||||
} from "../src/preview-zoom";
|
||||
|
||||
function createMemoryStorage(initialValue: string | null = null) {
|
||||
let value = initialValue;
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return key === PREVIEW_ZOOM_STORAGE_KEY ? value : null;
|
||||
},
|
||||
setItem(key: string, nextValue: string) {
|
||||
if (key === PREVIEW_ZOOM_STORAGE_KEY) {
|
||||
value = nextValue;
|
||||
}
|
||||
},
|
||||
read() {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("预览缩放", () => {
|
||||
it("默认使用 100%,并限制在 50% 到 400%", () => {
|
||||
expect(parsePreviewZoom(null)).toBe(DEFAULT_PREVIEW_ZOOM);
|
||||
expect(normalizePreviewZoom(20)).toBe(MINIMUM_PREVIEW_ZOOM);
|
||||
expect(normalizePreviewZoom(450)).toBe(MAXIMUM_PREVIEW_ZOOM);
|
||||
expect(normalizePreviewZoom(124.6)).toBe(125);
|
||||
});
|
||||
|
||||
it("无效手动输入回退到当前缩放值", () => {
|
||||
expect(parsePreviewZoom("", 135)).toBe(135);
|
||||
expect(parsePreviewZoom("not-a-number", 135)).toBe(135);
|
||||
});
|
||||
|
||||
it("保存并恢复浏览器缩放偏好", () => {
|
||||
const storage = createMemoryStorage();
|
||||
savePreviewZoom(145, storage);
|
||||
expect(storage.read()).toBe("145");
|
||||
expect(loadPreviewZoom(storage)).toBe(145);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user