- 新增可复用的 markdown-echarts 工作区,支持安全的 YAML 围栏、默认值、语义校验、错误占位和 SVG 渲染 - 将 ECharts 接入统一 Markdown、快速预览、精确预览与 PDF 链路,并复用 Mermaid 的不可跨页和超高图单页适配策略 - 默认示例加入 ECharts,增加源文本折叠、顶部文档状态、页码输入跳转与滚动同步 - 快速预览改用外层容器滚动,使滚动条与精确预览统一贴在预览区域右侧,并兼容显示缩放 - 完善 Docker 工作区复制、可配置 Compose 镜像、测试覆盖和进度文档
892 lines
26 KiB
TypeScript
892 lines
26 KiB
TypeScript
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>
|
||
);
|
||
}
|