Files
MorphDoc/apps/web/src/App.tsx
T
SkyJourney f5a46f79f1 feat: 增加 ECharts 渲染与预览交互增强
- 新增可复用的 markdown-echarts 工作区,支持安全的 YAML 围栏、默认值、语义校验、错误占位和 SVG 渲染
- 将 ECharts 接入统一 Markdown、快速预览、精确预览与 PDF 链路,并复用 Mermaid 的不可跨页和超高图单页适配策略
- 默认示例加入 ECharts,增加源文本折叠、顶部文档状态、页码输入跳转与滚动同步
- 快速预览改用外层容器滚动,使滚动条与精确预览统一贴在预览区域右侧,并兼容显示缩放
- 完善 Docker 工作区复制、可配置 Compose 镜像、测试覆盖和进度文档
2026-07-27 16:05:14 +08:00

892 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type CSSProperties,
type ChangeEvent,
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState
} from "react";
import {
createPagedDocumentPayload,
getPaperDimensionsMm,
millimetersToCssPixels,
type ExportConfig
} from "@md-to-pdf/core";
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
import { PreviewPageControl } from "./PreviewPageControl";
import { PreviewZoomControl } from "./PreviewZoomControl";
import {
loadExportConfig,
resetExportConfig,
saveExportConfig
} from "./export-settings";
import {
createPagedPreviewRenderRequest,
isPagedPreviewFrameMessage,
type PagedPreviewPayload
} from "./paged-preview";
import {
getNearestPageNumber,
getPreviewPageScrollTop
} from "./preview-page";
import {
createPdfExportCacheKey,
downloadPdf,
getCachedPdfExport,
getOrRequestPdfExport,
type CachedPdfExport,
type PdfExportRequest
} from "./pdf-export";
import { getSyncedScrollTop } from "./scroll-sync";
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");
return { default: module.PrecisePdfPreview };
});
type PreviewMode = "quick" | "precise";
const sampleMarkdown = `---
title: Markdown PDF 示例
author: 内网文档团队
keywords:
- Markdown
- PDF
---
# Markdown PDF 示例
这是一份使用 **Typora 风格主题** 渲染的预览文档。
## 常用内容
- [x] Markdown 实时预览
- [x] 表格与任务列表
- [x] 数学公式、Mermaid 与 ECharts
- [ ] Chromium PDF 导出
| 功能 | 当前状态 |
| --- | --- |
| HTML 预览 | 已实现 |
| PDF 下载 | 下一阶段 |
行内公式:$E = mc^2$
\`\`\`mermaid
flowchart LR
A["Markdown"] --> B["统一 HTML"]
B --> C["主题 CSS"]
C --> D["网页预览"]
C --> E["PDF 导出"]
\`\`\`
## ECharts 图表
\`\`\`echarts
caption: 各模块文档数量
height: 72mm
option:
xAxis:
type: category
data: [研发, 产品, 设计, 运营]
yAxis:
type: value
series:
- type: bar
data: [28, 18, 15, 22]
\`\`\`
> 预览和 PDF 将共用同一份 HTML 与 CSS。
`;
export function App() {
const [markdown, setMarkdown] = useState(sampleMarkdown);
const [fileName, setFileName] = useState("示例文档.md");
const [exportConfig, setExportConfig] =
useState<ExportConfig>(loadExportConfig);
const [settingsOpen, setSettingsOpen] = useState(false);
const [sourcePanelCollapsed, setSourcePanelCollapsed] = useState(false);
const [status, setStatus] = useState("正在准备预览…");
const [appError, setAppError] = useState("");
const [echartsError, setEChartsError] = useState("");
const [mermaidError, setMermaidError] = useState("");
const [paginationError, setPaginationError] = useState("");
const [pdfError, setPdfError] = useState("");
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 [quickPreviewContentHeight, setQuickPreviewContentHeight] =
useState<number>();
const [previewPagination, setPreviewPagination] = useState({
current: 0,
total: 0
});
const editorRef = useRef<HTMLTextAreaElement>(null);
const previewScrollRef = useRef<HTMLDivElement>(null);
const previewFrameRef = useRef<HTMLIFrameElement>(null);
const previewRequestIdRef = useRef(0);
const pdfRequestKeyRef = useRef("");
const previewModeRef = useRef<PreviewMode>("quick");
const scrollSyncFrameRef = useRef<number | undefined>(undefined);
const scrollSyncOriginRef = useRef<
"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(() => {
setEChartsError("");
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 ||
echartsError ||
mermaidError ||
paginationError ||
pdfError;
const themeId = exportConfig.themeId;
const selectedTheme = themes.find((theme) => theme.id === themeId);
const paperDimensions = getPaperDimensionsMm(
exportConfig.paper.format,
exportConfig.paper.orientation
);
const previewScale = previewZoom / 100;
const naturalPaperWidth = millimetersToCssPixels(paperDimensions.width);
const scaledPaperWidth = naturalPaperWidth * previewScale;
const paperStyle = {
width: `${scaledPaperWidth}px`,
minWidth: `${scaledPaperWidth}px`,
...(quickPreviewContentHeight
? {
height: `${quickPreviewContentHeight * previewScale}px`,
minHeight: `${quickPreviewContentHeight * previewScale}px`
}
: {})
} satisfies CSSProperties;
const previewFrameStyle = {
width: `${100 / previewScale}%`,
height: quickPreviewContentHeight
? `${quickPreviewContentHeight}px`
: `${100 / previewScale}%`,
transform: `scale(${previewScale})`,
transformOrigin: "top left"
} satisfies CSSProperties;
const previewPayload = useMemo<PagedPreviewPayload | undefined>(
() =>
result && themeCss
? createPagedDocumentPayload({
document: result,
fileName,
themeCss,
exportConfig
})
: undefined,
[exportConfig, fileName, result, themeCss]
);
const pdfRequest = useMemo<PdfExportRequest>(
() => ({
markdown,
fileName,
language: "zh-CN",
exportConfig
}),
[exportConfig, fileName, markdown]
);
const pdfCacheKey = useMemo(
() => createPdfExportCacheKey(pdfRequest),
[pdfRequest]
);
const cachedPdf = getCachedPdfExport(pdfCache, pdfCacheKey);
previewModeRef.current = previewMode;
pdfRequestKeyRef.current = pdfCacheKey;
useEffect(() => {
try {
saveExportConfig(exportConfig);
setAppError("");
} catch {
setAppError("无法保存导出设置,当前设置仅在本次页面中有效");
}
}, [exportConfig]);
useEffect(() => {
savePreviewZoom(previewZoom);
const frame = window.requestAnimationFrame(() => {
synchronizeScroll("editor");
updatePreviewCurrentPage();
});
return () => window.cancelAnimationFrame(frame);
}, [previewZoom]);
useEffect(() => {
if (pdfCache && pdfCache.key !== pdfCacheKey) {
setPdfCache(undefined);
setPreviewPagination({ current: 0, total: 0 });
}
}, [pdfCache, pdfCacheKey]);
useEffect(() => {
setPreviewFrameReady(false);
}, [themeId]);
useEffect(() => {
function handlePreviewFrameMessage(event: MessageEvent<unknown>) {
const frame = previewFrameRef.current;
if (
event.origin !== window.location.origin ||
event.source !== frame?.contentWindow ||
!isPagedPreviewFrameMessage(event.data)
) {
return;
}
if (event.data.type === "ready") {
setPreviewFrameReady(true);
return;
}
if (event.data.requestId !== previewRequestIdRef.current) {
return;
}
if (event.data.type === "rendering") {
setPaginationError("");
setEChartsError("");
setMermaidError("");
setQuickPreviewContentHeight(undefined);
setPreviewPagination({ current: 0, total: 0 });
setStatus("正在分页…");
return;
}
if (event.data.type === "error") {
setPaginationError(event.data.message);
setStatus("分页失败");
return;
}
setEChartsError(
event.data.echartsErrors.length > 0
? `ECharts${event.data.echartsErrors.join("")}`
: ""
);
setMermaidError(
event.data.mermaidErrors.length > 0
? `Mermaid${event.data.mermaidErrors.join("")}`
: ""
);
setStatus(`预览已分页(${event.data.pageCount} 页)`);
setQuickPreviewContentHeight(event.data.contentHeight);
setPreviewPagination({
current: event.data.pageCount > 0 ? 1 : 0,
total: event.data.pageCount
});
window.requestAnimationFrame(() => {
synchronizeScroll("editor");
window.requestAnimationFrame(updatePreviewCurrentPage);
});
}
window.addEventListener("message", handlePreviewFrameMessage);
return () =>
window.removeEventListener("message", handlePreviewFrameMessage);
}, []);
useEffect(() => {
function handleWindowScroll() {
if (getOuterPreviewScroller() === document.scrollingElement) {
updatePreviewCurrentPage();
}
}
window.addEventListener("scroll", handleWindowScroll, { passive: true });
return () => window.removeEventListener("scroll", handleWindowScroll);
}, [previewMode, quickPreviewContentHeight]);
useEffect(() => {
return () => {
if (scrollSyncFrameRef.current !== undefined) {
window.cancelAnimationFrame(scrollSyncFrameRef.current);
}
};
}, []);
useEffect(() => {
const frameWindow = previewFrameRef.current?.contentWindow;
if (!frameWindow || !previewFrameReady || !previewPayload) {
return;
}
previewRequestIdRef.current += 1;
frameWindow.postMessage(
createPagedPreviewRenderRequest(
previewRequestIdRef.current,
previewPayload
),
window.location.origin
);
}, [previewFrameReady, previewPayload]);
function getOuterPreviewScroller() {
const previewScroller = previewScrollRef.current;
if (!previewScroller) {
return null;
}
return previewScroller.scrollHeight > previewScroller.clientHeight + 1
? previewScroller
: (document.scrollingElement as HTMLElement | null);
}
function getPreviewScroller() {
return getOuterPreviewScroller();
}
function synchronizeScroll(origin: "editor" | "preview") {
const source =
origin === "editor" ? editorRef.current : getPreviewScroller();
const target =
origin === "editor" ? getPreviewScroller() : editorRef.current;
if (!source || !target) {
return;
}
const activeOrigin = scrollSyncOriginRef.current;
if (activeOrigin && activeOrigin !== origin) {
return;
}
scrollSyncOriginRef.current = origin;
target.scrollTop = getSyncedScrollTop(source, target);
if (scrollSyncFrameRef.current !== undefined) {
window.cancelAnimationFrame(scrollSyncFrameRef.current);
}
scrollSyncFrameRef.current = window.requestAnimationFrame(() => {
scrollSyncOriginRef.current = undefined;
scrollSyncFrameRef.current = undefined;
});
}
function updatePreviewCurrentPage() {
if (previewModeRef.current === "precise") {
updatePrecisePreviewCurrentPage();
return;
}
const frame = previewFrameRef.current;
const frameDocument = frame?.contentDocument;
const scrollElement = getPreviewScroller();
if (!frame || !frameDocument || !scrollElement) {
return;
}
const scrollerViewportTop =
scrollElement === document.scrollingElement
? 0
: scrollElement.getBoundingClientRect().top;
const frameViewportTop = frame.getBoundingClientRect().top;
const viewportCenter =
scrollElement.scrollTop + scrollElement.clientHeight / 2;
const pages = Array.from(
frameDocument.querySelectorAll<HTMLElement>(".pagedjs_page")
).map((page) => {
const rect = page.getBoundingClientRect();
return {
top: getPreviewPageScrollTop(
scrollElement.scrollTop,
frameViewportTop + rect.top * previewScale,
scrollerViewportTop
),
bottom: getPreviewPageScrollTop(
scrollElement.scrollTop,
frameViewportTop + rect.bottom * previewScale,
scrollerViewportTop
)
};
});
const current = getNearestPageNumber(viewportCenter, pages);
setPreviewPagination((pagination) =>
pagination.current === current &&
pagination.total === pages.length
? pagination
: {
current,
total: pages.length
}
);
}
function updatePrecisePreviewCurrentPage() {
const scrollElement = getOuterPreviewScroller();
if (!scrollElement) {
return;
}
const scrollerViewportTop =
scrollElement === document.scrollingElement
? 0
: scrollElement.getBoundingClientRect().top;
const viewportCenter =
scrollElement.scrollTop + scrollElement.clientHeight / 2;
const pages = Array.from(
scrollElement.querySelectorAll<HTMLElement>(
".precise-pdf-page"
)
).map((page) => {
const rect = page.getBoundingClientRect();
return {
top: getPreviewPageScrollTop(
scrollElement.scrollTop,
rect.top,
scrollerViewportTop
),
bottom: getPreviewPageScrollTop(
scrollElement.scrollTop,
rect.bottom,
scrollerViewportTop
)
};
});
const current = getNearestPageNumber(viewportCenter, pages);
setPreviewPagination((pagination) =>
pagination.current === current &&
pagination.total === pages.length
? pagination
: {
current,
total: pages.length
}
);
}
function handlePreviewPageChange(pageNumber: number) {
const scrollElement = getPreviewScroller();
if (!scrollElement) {
return;
}
let targetTop: number | undefined;
if (previewModeRef.current === "precise") {
const page = scrollElement.querySelectorAll<HTMLElement>(
".precise-pdf-page"
)[pageNumber - 1];
if (page) {
const scrollerViewportTop =
scrollElement === document.scrollingElement
? 0
: scrollElement.getBoundingClientRect().top;
targetTop = getPreviewPageScrollTop(
scrollElement.scrollTop,
page.getBoundingClientRect().top,
scrollerViewportTop
);
}
} else {
const page =
previewFrameRef.current?.contentDocument?.querySelectorAll<HTMLElement>(
".pagedjs_page"
)[pageNumber - 1];
if (page) {
const scrollerViewportTop =
scrollElement === document.scrollingElement
? 0
: scrollElement.getBoundingClientRect().top;
const frameViewportTop =
previewFrameRef.current?.getBoundingClientRect().top ?? 0;
targetTop = getPreviewPageScrollTop(
scrollElement.scrollTop,
frameViewportTop +
page.getBoundingClientRect().top * previewScale,
scrollerViewportTop
);
}
}
if (targetTop === undefined) {
return;
}
setPreviewPagination((pagination) => ({
...pagination,
current: pageNumber
}));
scrollElement.scrollTo({ top: Math.max(0, targetTop) });
}
const handlePrecisePageCount = useCallback((pageCount: number) => {
setPreviewPagination({
current: pageCount > 0 ? 1 : 0,
total: pageCount
});
window.requestAnimationFrame(updatePrecisePreviewCurrentPage);
}, []);
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
return;
}
try {
setAppError("");
setMarkdown(await file.text());
setFileName(file.name);
} catch {
setAppError("无法读取所选 Markdown 文件");
} finally {
event.target.value = "";
}
}
async function generatePdf(download: boolean) {
if (exportingPdf) {
return;
}
setExportingPdf(true);
setPdfError("");
setStatus(
download ? "正在生成 PDF…" : "正在生成精确预览…"
);
const requestedKey = pdfCacheKey;
try {
const resolved = await getOrRequestPdfExport(
pdfRequest,
pdfCache
);
if (pdfRequestKeyRef.current !== requestedKey) {
setStatus("文档已更新,已丢弃过期 PDF");
return;
}
setPdfCache(resolved);
const exported = resolved.result;
if (download) {
downloadPdf(exported.blob, exported.fileName);
}
const pageDescription =
exported.pageCount === undefined
? ""
: `${exported.pageCount} 页)`;
const mermaidDescription =
exported.mermaidErrorCount > 0
? `${exported.mermaidErrorCount} 个 Mermaid 图表渲染失败`
: "";
const echartsDescription =
exported.echartsErrorCount > 0
? `${exported.echartsErrorCount} 个 ECharts 图表渲染失败`
: "";
setStatus(
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${echartsDescription}${mermaidDescription}`
);
} catch (reason) {
setPdfError(
reason instanceof Error ? reason.message : "PDF 导出失败"
);
setStatus(download ? "PDF 导出失败" : "精确预览失败");
} finally {
setExportingPdf(false);
}
}
function handlePreviewModeChange(mode: PreviewMode) {
if (mode === previewMode) {
return;
}
setPreviewMode(mode);
setPreviewFrameReady(false);
setQuickPreviewContentHeight(undefined);
setPreviewPagination({ current: 0, total: 0 });
previewScrollRef.current?.scrollTo({ top: 0 });
if (mode === "precise" && !cachedPdf) {
void generatePdf(false);
}
}
function handlePreviewScroll() {
synchronizeScroll("preview");
updatePreviewCurrentPage();
}
return (
<main className="workspace">
<header className="topbar">
<div className="topbar-primary">
<div className="topbar-brand">
<p className="eyebrow">内网文档工具</p>
<h1>Markdown PDF 导出器</h1>
</div>
<div className="document-summary">
<span className="panel-kicker">源文件</span>
<strong title={fileName}>{fileName}</strong>
<span
className={`render-status${error ? " is-error" : ""}`}
aria-live="polite"
title={error || status}
>
{error || status}
</span>
</div>
</div>
<div className="topbar-actions">
<label className="file-button">
选择 Markdown
<input
type="file"
accept=".md,.markdown,text/markdown,text/plain"
onChange={handleFileChange}
/>
</label>
<button type="button" onClick={() => setSettingsOpen(true)}>
导出设置
</button>
<button
type="button"
disabled={exportingPdf || !markdown.trim()}
onClick={() => void generatePdf(true)}
>
{exportingPdf ? "正在导出…" : "导出 PDF"}
</button>
</div>
</header>
<section
className={`editor-layout${
sourcePanelCollapsed ? " is-source-collapsed" : ""
}`}
>
<aside className="editor-panel" aria-label="Markdown 编辑区">
<div
id="markdown-source-content"
className="source-panel-content"
hidden={sourcePanelCollapsed}
>
<div className="panel-heading">
<div>
<span className="panel-kicker">源文本</span>
<strong>Markdown 原文</strong>
</div>
<button
type="button"
className="source-panel-toggle"
aria-controls="markdown-source-content"
aria-expanded="true"
onClick={() => setSourcePanelCollapsed(true)}
>
收起
</button>
</div>
<textarea
ref={editorRef}
aria-label="Markdown 内容"
value={markdown}
spellCheck={false}
onChange={(event) => setMarkdown(event.target.value)}
onScroll={() => synchronizeScroll("editor")}
/>
</div>
{sourcePanelCollapsed ? (
<button
type="button"
className="source-panel-restore"
aria-controls="markdown-source-content"
aria-expanded="false"
onClick={() => setSourcePanelCollapsed(false)}
>
展开源文本
</button>
) : null}
</aside>
<section className="preview-panel" aria-label="文档预览">
<div className="panel-heading">
<div>
<span className="panel-kicker">实时预览</span>
<strong>{result?.metadata.title || "未命名文档"}</strong>
</div>
<span className="preview-heading-actions">
<span
className="preview-mode-control"
role="group"
aria-label="预览模式"
>
<button
type="button"
className={
previewMode === "quick" ? "is-active" : ""
}
aria-pressed={previewMode === "quick"}
onClick={() => handlePreviewModeChange("quick")}
>
快速
</button>
<button
type="button"
className={
previewMode === "precise" ? "is-active" : ""
}
aria-pressed={previewMode === "precise"}
disabled={exportingPdf}
onClick={() => handlePreviewModeChange("precise")}
>
精确
</button>
</span>
<label className="theme-control">
<span className="sr-only">预览主题</span>
<select
aria-label="预览主题"
value={themeId}
onChange={(event) =>
setExportConfig((currentConfig) => ({
...currentConfig,
themeId: event.target.value
}))
}
>
{themes.length === 0 ? (
<option value={themeId}>正在加载主题…</option>
) : (
themes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))
)}
</select>
{selectedTheme?.source === "local" ? (
<span className="local-theme-mark">本地</span>
) : null}
</label>
</span>
</div>
<div className="preview-toolbar">
{previewPagination.total > 0 ? (
<PreviewPageControl
current={previewPagination.current}
total={previewPagination.total}
onNavigate={handlePreviewPageChange}
/>
) : (
<span />
)}
<PreviewZoomControl
value={previewZoom}
onChange={setPreviewZoom}
/>
</div>
<div
ref={previewScrollRef}
className="preview-scroll"
onScroll={handlePreviewScroll}
>
{previewMode === "quick" ? (
<div className="paper" style={paperStyle}>
{previewPayload ? (
<iframe
ref={previewFrameRef}
className="preview-frame"
style={previewFrameStyle}
title="文档内容预览"
sandbox="allow-same-origin allow-scripts"
src="/preview-frame.html"
/>
) : (
<div className="preview-loading">
正在生成文档预览…
</div>
)}
</div>
) : exportingPdf && !cachedPdf ? (
<div className="precise-pdf-status">
正在使用固定版本 Chromium 生成精确预览…
</div>
) : cachedPdf ? (
<Suspense
fallback={
<div className="precise-pdf-status">
正在加载 PDF 查看器…
</div>
}
>
<PrecisePdfPreview
blob={cachedPdf.blob}
zoomPercent={previewZoom}
onPageCount={handlePrecisePageCount}
/>
</Suspense>
) : (
<div className="precise-pdf-status">
<span>当前内容尚未生成精确预览</span>
<button
type="button"
onClick={() => void generatePdf(false)}
>
生成精确预览
</button>
</div>
)}
</div>
</section>
</section>
{settingsOpen ? (
<ExportSettingsDrawer
config={exportConfig}
onChange={setExportConfig}
onClose={() => setSettingsOpen(false)}
onReset={() => setExportConfig(resetExportConfig())}
/>
) : null}
</main>
);
}