refactor: 收敛渲染链路共享抽象
This commit is contained in:
+12
-11
@@ -4,6 +4,7 @@ import { performance } from "node:perf_hooks";
|
||||
import Fastify from "fastify";
|
||||
import {
|
||||
EXPORT_CONFIG_VERSION,
|
||||
createPagedDocumentPayload,
|
||||
defaultExportConfig,
|
||||
exportConfigSchema,
|
||||
supportedPaperFormats
|
||||
@@ -354,17 +355,17 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
const rendered = renderedResult.document;
|
||||
|
||||
try {
|
||||
const generated = await pdfGenerator.generate({
|
||||
articleHtml: rendered.articleHtml,
|
||||
fileName:
|
||||
typeof request.body.fileName === "string"
|
||||
? request.body.fileName
|
||||
: "文档.md",
|
||||
metadata: rendered.metadata,
|
||||
features: rendered.features,
|
||||
themeCss,
|
||||
exportConfig: parsedConfig.data
|
||||
});
|
||||
const generated = await pdfGenerator.generate(
|
||||
createPagedDocumentPayload({
|
||||
document: rendered,
|
||||
fileName:
|
||||
typeof request.body.fileName === "string"
|
||||
? request.body.fileName
|
||||
: "文档.md",
|
||||
themeCss,
|
||||
exportConfig: parsedConfig.data
|
||||
})
|
||||
);
|
||||
const pdfFileName = createPdfFileName(request.body.fileName);
|
||||
const requestMs = performance.now() - requestStartedAt;
|
||||
const serverTiming = createPdfServerTiming(
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import { chromium, type Browser } from "playwright";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
import type {
|
||||
PagedDocumentPayload,
|
||||
PagedDocumentRenderResult
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export interface PdfRenderPayload {
|
||||
articleHtml: string;
|
||||
fileName: string;
|
||||
metadata: {
|
||||
title: string;
|
||||
author: string;
|
||||
subject: string;
|
||||
keywords: string[];
|
||||
language: string;
|
||||
};
|
||||
features: string[];
|
||||
themeCss: string;
|
||||
exportConfig: ExportConfig;
|
||||
}
|
||||
export type PdfRenderPayload = PagedDocumentPayload;
|
||||
|
||||
export interface PdfGenerationResult {
|
||||
pdf: Buffer;
|
||||
@@ -62,22 +52,6 @@ export interface PdfEngineRuntimeLimits {
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
interface PagedRuntimeResult {
|
||||
pageCount: number;
|
||||
contentHeight: number;
|
||||
mermaidErrors: string[];
|
||||
timings: {
|
||||
setupMs: number;
|
||||
mermaidMs: number;
|
||||
mermaidFitMs: number;
|
||||
mermaidConversionMs: number;
|
||||
resourceWaitMs: number;
|
||||
paginationMs: number;
|
||||
finalizeMs: number;
|
||||
totalMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface PdfDocumentResult
|
||||
extends Omit<PdfGenerationResult, "timings"> {
|
||||
timings: Pick<
|
||||
@@ -457,13 +431,13 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
|
||||
|
||||
const documentRenderStartedAt = performance.now();
|
||||
const renderResult = await page.evaluate(
|
||||
async (renderPayload): Promise<PagedRuntimeResult> => {
|
||||
async (renderPayload): Promise<PagedDocumentRenderResult> => {
|
||||
const renderer = (
|
||||
window as typeof window & {
|
||||
__mdToPdfRender?: (
|
||||
payload: PdfRenderPayload,
|
||||
target: "pdf"
|
||||
) => Promise<PagedRuntimeResult>;
|
||||
) => Promise<PagedDocumentRenderResult>;
|
||||
}
|
||||
).__mdToPdfRender;
|
||||
if (!renderer) {
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface ThemeRegistryOptions {
|
||||
bundledRoot: string;
|
||||
localRoot: string;
|
||||
onWarning?: (message: string) => void;
|
||||
cacheTtlMs?: number;
|
||||
}
|
||||
|
||||
const assetContentTypes: Record<string, string> = {
|
||||
@@ -275,7 +276,18 @@ async function loadThemeCssFile(
|
||||
}
|
||||
|
||||
export function createThemeRegistry(options: ThemeRegistryOptions) {
|
||||
async function list() {
|
||||
const cacheTtlMs = options.cacheTtlMs ?? 1_000;
|
||||
if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0) {
|
||||
throw new Error("主题缓存有效期必须是非负有限数值");
|
||||
}
|
||||
let cachedThemes:
|
||||
| {
|
||||
expiresAt: number;
|
||||
promise: Promise<ThemeRecord[]>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
async function scanThemes() {
|
||||
const [bundledThemes, localThemes] = await Promise.all([
|
||||
readThemeRoot(options.bundledRoot, "bundled"),
|
||||
readThemeRoot(options.localRoot, "local", options.onWarning)
|
||||
@@ -293,6 +305,29 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
|
||||
return themes;
|
||||
}
|
||||
|
||||
function list() {
|
||||
const now = Date.now();
|
||||
if (cachedThemes && now < cachedThemes.expiresAt) {
|
||||
return cachedThemes.promise;
|
||||
}
|
||||
|
||||
const promise = scanThemes();
|
||||
cachedThemes = {
|
||||
expiresAt: now + cacheTtlMs,
|
||||
promise
|
||||
};
|
||||
void promise.catch(() => {
|
||||
if (cachedThemes?.promise === promise) {
|
||||
cachedThemes = undefined;
|
||||
}
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
cachedThemes = undefined;
|
||||
}
|
||||
|
||||
async function get(themeId: string) {
|
||||
return (await list()).find((theme) => theme.manifest.id === themeId);
|
||||
}
|
||||
@@ -340,6 +375,7 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
|
||||
get,
|
||||
getAsset,
|
||||
getCss,
|
||||
invalidate,
|
||||
list
|
||||
};
|
||||
}
|
||||
|
||||
@@ -287,6 +287,58 @@ describe("预览 API", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("缓存主题目录扫描并支持主动失效", async () => {
|
||||
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
|
||||
const bundledRoot = join(temporaryDirectory, "bundled");
|
||||
const localRoot = join(temporaryDirectory, "local");
|
||||
const firstThemeRoot = join(localRoot, "first-theme");
|
||||
const secondThemeRoot = join(localRoot, "second-theme");
|
||||
await mkdir(bundledRoot, { recursive: true });
|
||||
await mkdir(firstThemeRoot, { recursive: true });
|
||||
await writeFile(
|
||||
join(firstThemeRoot, "theme.json"),
|
||||
JSON.stringify(localThemeManifest("first-theme")),
|
||||
"utf8"
|
||||
);
|
||||
await writeFile(
|
||||
join(firstThemeRoot, "theme.css"),
|
||||
"#write { color: #333; }",
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const registry = createThemeRegistry({
|
||||
bundledRoot,
|
||||
localRoot,
|
||||
cacheTtlMs: 60_000
|
||||
});
|
||||
await expect(registry.list()).resolves.toHaveLength(1);
|
||||
|
||||
await mkdir(secondThemeRoot, { recursive: true });
|
||||
await writeFile(
|
||||
join(secondThemeRoot, "theme.json"),
|
||||
JSON.stringify(localThemeManifest("second-theme")),
|
||||
"utf8"
|
||||
);
|
||||
await writeFile(
|
||||
join(secondThemeRoot, "theme.css"),
|
||||
"#write { color: #666; }",
|
||||
"utf8"
|
||||
);
|
||||
await expect(registry.list()).resolves.toHaveLength(1);
|
||||
|
||||
registry.invalidate();
|
||||
await expect(registry.list()).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
manifest: expect.objectContaining({ id: "first-theme" })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
manifest: expect.objectContaining({ id: "second-theme" })
|
||||
})
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("拒绝主题 CSS 导入外部地址", async () => {
|
||||
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
|
||||
const bundledRoot = join(temporaryDirectory, "bundled");
|
||||
|
||||
@@ -47,7 +47,6 @@ function fakeBrowser(
|
||||
waitForFunction: vi.fn(async () => undefined),
|
||||
evaluate: vi.fn(async () => ({
|
||||
pageCount: 2,
|
||||
contentHeight: 2200,
|
||||
mermaidErrors: [],
|
||||
timings: {
|
||||
setupMs: 1,
|
||||
|
||||
+52
-144
@@ -10,6 +10,7 @@ import {
|
||||
useState
|
||||
} from "react";
|
||||
import {
|
||||
createPagedDocumentPayload,
|
||||
getPaperDimensionsMm,
|
||||
millimetersToCssPixels,
|
||||
type ExportConfig
|
||||
@@ -43,6 +44,11 @@ import {
|
||||
loadPreviewZoom,
|
||||
savePreviewZoom
|
||||
} from "./preview-zoom";
|
||||
import { useMarkdownRender } from "./use-markdown-render";
|
||||
import {
|
||||
type ThemeSummary,
|
||||
useThemeResources
|
||||
} from "./use-theme-resources";
|
||||
|
||||
const PrecisePdfPreview = lazy(async () => {
|
||||
const module = await import("./PrecisePdfPreview");
|
||||
@@ -51,28 +57,6 @@ const PrecisePdfPreview = lazy(async () => {
|
||||
|
||||
type PreviewMode = "quick" | "precise";
|
||||
|
||||
interface RenderedMarkdown {
|
||||
articleHtml: string;
|
||||
metadata: {
|
||||
title: string;
|
||||
author: string;
|
||||
subject: string;
|
||||
keywords: string[];
|
||||
language: string;
|
||||
};
|
||||
features: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
interface ThemeSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
bundled: boolean;
|
||||
source: "bundled" | "local";
|
||||
}
|
||||
|
||||
const sampleMarkdown = `---
|
||||
title: Markdown PDF 示例
|
||||
author: 内网文档团队
|
||||
@@ -113,15 +97,11 @@ flowchart LR
|
||||
export function App() {
|
||||
const [markdown, setMarkdown] = useState(sampleMarkdown);
|
||||
const [fileName, setFileName] = useState("示例文档.md");
|
||||
const [result, setResult] = useState<RenderedMarkdown | null>(null);
|
||||
const [themes, setThemes] = useState<ThemeSummary[]>([]);
|
||||
const [exportConfig, setExportConfig] =
|
||||
useState<ExportConfig>(loadExportConfig);
|
||||
const [themeCss, setThemeCss] = useState("");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [status, setStatus] = useState("正在准备预览…");
|
||||
const [renderError, setRenderError] = useState("");
|
||||
const [themeError, setThemeError] = useState("");
|
||||
const [appError, setAppError] = useState("");
|
||||
const [mermaidError, setMermaidError] = useState("");
|
||||
const [paginationError, setPaginationError] = useState("");
|
||||
const [pdfError, setPdfError] = useState("");
|
||||
@@ -146,9 +126,47 @@ export function App() {
|
||||
"editor" | "preview" | undefined
|
||||
>(undefined);
|
||||
|
||||
const handleThemesLoaded = useCallback(
|
||||
(availableThemes: ThemeSummary[]) => {
|
||||
setExportConfig((currentConfig) => {
|
||||
if (
|
||||
availableThemes.some(
|
||||
(theme) => theme.id === currentConfig.themeId
|
||||
)
|
||||
) {
|
||||
return currentConfig;
|
||||
}
|
||||
const preferredTheme =
|
||||
availableThemes.find((theme) => theme.id === "typora-github") ??
|
||||
availableThemes.find((theme) => theme.id === "typora-like") ??
|
||||
availableThemes[0];
|
||||
return preferredTheme
|
||||
? { ...currentConfig, themeId: preferredTheme.id }
|
||||
: currentConfig;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleRenderStart = useCallback(() => {
|
||||
setMermaidError("");
|
||||
}, []);
|
||||
const { error: renderError, result } = useMarkdownRender(
|
||||
markdown,
|
||||
"zh-CN",
|
||||
{
|
||||
onRenderStart: handleRenderStart,
|
||||
onStatusChange: setStatus
|
||||
}
|
||||
);
|
||||
const {
|
||||
error: themeError,
|
||||
themeCss,
|
||||
themes
|
||||
} = useThemeResources(exportConfig.themeId, handleThemesLoaded);
|
||||
const error =
|
||||
renderError ||
|
||||
themeError ||
|
||||
appError ||
|
||||
mermaidError ||
|
||||
paginationError ||
|
||||
pdfError;
|
||||
@@ -176,14 +194,12 @@ export function App() {
|
||||
const previewPayload = useMemo<PagedPreviewPayload | undefined>(
|
||||
() =>
|
||||
result && themeCss
|
||||
? {
|
||||
articleHtml: result.articleHtml,
|
||||
? createPagedDocumentPayload({
|
||||
document: result,
|
||||
fileName,
|
||||
metadata: result.metadata,
|
||||
features: result.features,
|
||||
themeCss,
|
||||
exportConfig
|
||||
}
|
||||
})
|
||||
: undefined,
|
||||
[exportConfig, fileName, result, themeCss]
|
||||
);
|
||||
@@ -204,53 +220,12 @@ export function App() {
|
||||
previewModeRef.current = previewMode;
|
||||
pdfRequestKeyRef.current = pdfCacheKey;
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
void fetch("/api/themes", {
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("无法加载主题清单");
|
||||
}
|
||||
return response.json() as Promise<{ themes: ThemeSummary[] }>;
|
||||
})
|
||||
.then(({ themes: availableThemes }) => {
|
||||
setThemes(availableThemes);
|
||||
setExportConfig((currentConfig) => {
|
||||
if (
|
||||
availableThemes.some(
|
||||
(theme) => theme.id === currentConfig.themeId
|
||||
)
|
||||
) {
|
||||
return currentConfig;
|
||||
}
|
||||
const preferredTheme =
|
||||
availableThemes.find((theme) => theme.id === "typora-github") ??
|
||||
availableThemes.find((theme) => theme.id === "typora-like") ??
|
||||
availableThemes[0];
|
||||
return preferredTheme
|
||||
? { ...currentConfig, themeId: preferredTheme.id }
|
||||
: currentConfig;
|
||||
});
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setThemeError(
|
||||
reason instanceof Error ? reason.message : "无法加载主题清单"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
saveExportConfig(exportConfig);
|
||||
setAppError("");
|
||||
} catch {
|
||||
setRenderError("无法保存导出设置,当前设置仅在本次页面中有效");
|
||||
setAppError("无法保存导出设置,当前设置仅在本次页面中有效");
|
||||
}
|
||||
}, [exportConfig]);
|
||||
|
||||
@@ -271,77 +246,9 @@ export function App() {
|
||||
}, [pdfCache, pdfCacheKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setPreviewFrameReady(false);
|
||||
setThemeCss("");
|
||||
setThemeError("");
|
||||
|
||||
void fetch(`/api/themes/${encodeURIComponent(themeId)}/css`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("无法加载主题");
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(setThemeCss)
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setThemeError(
|
||||
reason instanceof Error ? reason.message : "无法加载主题"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [themeId]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => {
|
||||
setStatus("正在渲染…");
|
||||
setRenderError("");
|
||||
setMermaidError("");
|
||||
|
||||
void fetch("/api/render", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
markdown,
|
||||
language: "zh-CN"
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async (response) => {
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message ?? "渲染失败");
|
||||
}
|
||||
return payload as RenderedMarkdown;
|
||||
})
|
||||
.then((payload) => {
|
||||
setResult(payload);
|
||||
setStatus("预览已更新");
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setRenderError(
|
||||
reason instanceof Error ? reason.message : "渲染失败"
|
||||
);
|
||||
setStatus("预览失败");
|
||||
}
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [markdown]);
|
||||
|
||||
useEffect(() => {
|
||||
function handlePreviewFrameMessage(event: MessageEvent<unknown>) {
|
||||
const frame = previewFrameRef.current;
|
||||
@@ -552,10 +459,11 @@ export function App() {
|
||||
}
|
||||
|
||||
try {
|
||||
setAppError("");
|
||||
setMarkdown(await file.text());
|
||||
setFileName(file.name);
|
||||
} catch {
|
||||
setRenderError("无法读取所选 Markdown 文件");
|
||||
setAppError("无法读取所选 Markdown 文件");
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import highlightCss from "highlight.js/styles/github.css?inline";
|
||||
import katexCss from "katex/dist/katex.min.css?inline";
|
||||
import type {
|
||||
PagedDocumentRenderResult,
|
||||
PagedDocumentTimings
|
||||
} from "@md-to-pdf/core";
|
||||
import { Previewer } from "pagedjs";
|
||||
import { createMermaidSiteConfig } from "./mermaid-config";
|
||||
import {
|
||||
@@ -20,23 +24,10 @@ import {
|
||||
} from "./paged-preview";
|
||||
import type { PagedRenderTarget } from "./paged-render-target";
|
||||
|
||||
export interface PagedDocumentRenderResult {
|
||||
pageCount: number;
|
||||
contentHeight: number;
|
||||
mermaidErrors: string[];
|
||||
timings: PagedDocumentTimings;
|
||||
}
|
||||
|
||||
export interface PagedDocumentTimings {
|
||||
setupMs: number;
|
||||
mermaidMs: number;
|
||||
mermaidFitMs: number;
|
||||
mermaidConversionMs: number;
|
||||
resourceWaitMs: number;
|
||||
paginationMs: number;
|
||||
finalizeMs: number;
|
||||
totalMs: number;
|
||||
}
|
||||
export type {
|
||||
PagedDocumentRenderResult,
|
||||
PagedDocumentTimings
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export interface PagedDocumentRenderOptions {
|
||||
target: PagedRenderTarget;
|
||||
@@ -364,13 +355,6 @@ export class PagedDocumentRuntime {
|
||||
|
||||
return {
|
||||
pageCount: flow.total,
|
||||
contentHeight: Math.ceil(
|
||||
Math.max(
|
||||
documentRef.documentElement.scrollHeight,
|
||||
documentRef.body.scrollHeight,
|
||||
this.root.scrollHeight
|
||||
)
|
||||
),
|
||||
mermaidErrors,
|
||||
timings: {
|
||||
setupMs,
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
import {
|
||||
getPaperDimensionsMm,
|
||||
type ExportConfig
|
||||
type ExportConfig,
|
||||
type MarkdownDocumentMetadata,
|
||||
type PagedDocumentPayload
|
||||
} from "@md-to-pdf/core";
|
||||
import { PREVIEW_SCROLLBAR_WIDTH_PX } from "./preview-page";
|
||||
|
||||
export const PAGED_PREVIEW_MESSAGE_SCOPE = "md-to-pdf:paged-preview";
|
||||
|
||||
export interface PreviewMetadata {
|
||||
title: string;
|
||||
author: string;
|
||||
subject: string;
|
||||
keywords: string[];
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface PagedPreviewPayload {
|
||||
articleHtml: string;
|
||||
fileName: string;
|
||||
metadata: PreviewMetadata;
|
||||
features: string[];
|
||||
themeCss: string;
|
||||
exportConfig: ExportConfig;
|
||||
}
|
||||
export type PreviewMetadata = MarkdownDocumentMetadata;
|
||||
export type PagedPreviewPayload = PagedDocumentPayload;
|
||||
|
||||
export interface PagedPreviewRenderRequest {
|
||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||
@@ -45,7 +33,6 @@ export type PagedPreviewFrameMessage =
|
||||
type: "rendered";
|
||||
requestId: number;
|
||||
pageCount: number;
|
||||
contentHeight: number;
|
||||
mermaidErrors: string[];
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export const PDF_POINTS_PER_INCH = 72;
|
||||
export const CSS_PIXELS_PER_INCH = 96;
|
||||
import {
|
||||
CSS_PIXELS_PER_INCH,
|
||||
PDF_POINTS_PER_INCH
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export const PDF_PAGE_CSS_SCALE =
|
||||
CSS_PIXELS_PER_INCH / PDF_POINTS_PER_INCH;
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
|
||||
|
||||
export interface MarkdownRenderCallbacks {
|
||||
onRenderStart?: () => void;
|
||||
onStatusChange?: (status: string) => void;
|
||||
}
|
||||
|
||||
export function useMarkdownRender(
|
||||
markdown: string,
|
||||
language: string,
|
||||
callbacks: MarkdownRenderCallbacks = {}
|
||||
) {
|
||||
const [result, setResult] =
|
||||
useState<RenderedMarkdownDocument | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const { onRenderStart, onStatusChange } = callbacks;
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => {
|
||||
onStatusChange?.("正在渲染…");
|
||||
onRenderStart?.();
|
||||
setError("");
|
||||
|
||||
void fetch("/api/render", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ markdown, language }),
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async (response) => {
|
||||
const payload = (await response.json()) as
|
||||
| RenderedMarkdownDocument
|
||||
| { message?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
"message" in payload
|
||||
? payload.message ?? "渲染失败"
|
||||
: "渲染失败"
|
||||
);
|
||||
}
|
||||
return payload as RenderedMarkdownDocument;
|
||||
})
|
||||
.then((payload) => {
|
||||
setResult(payload);
|
||||
onStatusChange?.("预览已更新");
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "渲染失败"
|
||||
);
|
||||
onStatusChange?.("预览失败");
|
||||
}
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [language, markdown, onRenderStart, onStatusChange]);
|
||||
|
||||
return { error, result };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface ThemeSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
bundled: boolean;
|
||||
source: "bundled" | "local";
|
||||
}
|
||||
|
||||
export function useThemeResources(
|
||||
themeId: string,
|
||||
onThemesLoaded: (themes: ThemeSummary[]) => void
|
||||
) {
|
||||
const [themes, setThemes] = useState<ThemeSummary[]>([]);
|
||||
const [themeCss, setThemeCss] = useState("");
|
||||
const [catalogError, setCatalogError] = useState("");
|
||||
const [cssError, setCssError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
void fetch("/api/themes", {
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("无法加载主题清单");
|
||||
}
|
||||
return response.json() as Promise<{ themes: ThemeSummary[] }>;
|
||||
})
|
||||
.then(({ themes: availableThemes }) => {
|
||||
setThemes(availableThemes);
|
||||
setCatalogError("");
|
||||
onThemesLoaded(availableThemes);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setCatalogError(
|
||||
reason instanceof Error ? reason.message : "无法加载主题清单"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [onThemesLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setThemeCss("");
|
||||
setCssError("");
|
||||
|
||||
void fetch(`/api/themes/${encodeURIComponent(themeId)}/css`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("无法加载主题");
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(setThemeCss)
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setCssError(
|
||||
reason instanceof Error ? reason.message : "无法加载主题"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [themeId]);
|
||||
|
||||
return {
|
||||
error: catalogError || cssError,
|
||||
themeCss,
|
||||
themes
|
||||
};
|
||||
}
|
||||
@@ -178,7 +178,6 @@ describe("分页预览协议", () => {
|
||||
type: "rendered",
|
||||
requestId: 3,
|
||||
pageCount: 2,
|
||||
contentHeight: 2300,
|
||||
mermaidErrors: []
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user