Files
MorphDoc/apps/web/src/App.tsx
T
SkyJourney 925b0d1485 release: 发布 v0.4.5
新增能力:Web 与 Desktop 支持新建和保存 Markdown;桌面端注册 .md/.markdown 打开程序,支持单实例文件转交、自定义主题目录以及窗口位置、尺寸和最大化状态的延迟原子持久化与屏幕越界修正。

界面与兼容性:保留打开 Markdown 为独立主操作,将新建、保存和主题目录收纳到更多菜单;Web 移除本地素材目录入口并明确默认不支持本地素材;新文档默认使用一级标题或首行合法化生成文件名,可不保存直接导出 PDF。

渲染修复:图片、Mermaid 和 ECharts 按文档顺序串行执行媒体分页回填,每个媒体元素至多重排一次;图片以限宽后的高度作为缩放基准,只缩放媒体主体并保持标题尺寸;ECharts 冻结为 SVG 图片后参与统一分页。

示例与文档:默认示例仅保留一张网络图片,增加有效 Base64 横图、竖图、边界图片及多尺寸 Mermaid/ECharts;更新根 README、Desktop README、部署文档、四个 packages README、PROGRESS 和 AGENTS 发布规范;登记 v0.4.6 超链接导航问题。

部署与验证:构建并运行 yixiong/md-to-pdf:v0.4.5 正式镜像,Compose 健康且 PDF 冒烟无 Mermaid/ECharts 错误;生成 NSIS 安装包和免安装 ZIP,本机已升级到 NSIS v0.4.5 并清理旧 Squirrel 安装;202 项测试、类型检查、生产构建和 git diff --check 全部通过。

发布产物:Setup.exe SHA-256 9CBD6362E15AA745185805E753D5A7749434E0E142E6CE0F66463E6A19468F1D;ZIP SHA-256 97B2D44E72F626A3396E5AB5B330FFB7F9BECAF945A1707808C876940A82E850;当前 Windows 产物未配置代码签名。
2026-07-28 13:31:42 +08:00

1153 lines
35 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,
requestDesktopPdfExport,
type CachedPdfExport,
type PdfExportRequest
} from "./pdf-export";
import { APP_VERSION_LABEL } from "./app-version";
import { getSyncedScrollTop } from "./scroll-sync";
import {
loadPreviewZoom,
savePreviewZoom
} from "./preview-zoom";
import { useMarkdownRender } from "./use-markdown-render";
import {
createMarkdownFileName,
downloadMarkdown,
ensureMarkdownFileName
} from "./document-file";
import {
type ThemeSummary,
useThemeResources
} from "./use-theme-resources";
import { sampleMarkdown } from "./sample-markdown";
const PrecisePdfPreview = lazy(async () => {
const module = await import("./PrecisePdfPreview");
return { default: module.PrecisePdfPreview };
});
type PreviewMode = "quick" | "precise";
type DocumentKind = "sample" | "new" | "file";
const noImageResources: never[] = [];
export function App() {
const [markdown, setMarkdown] = useState(sampleMarkdown);
const [fileName, setFileName] = useState("示例文档.md");
const [documentKind, setDocumentKind] =
useState<DocumentKind>("sample");
const [savedMarkdown, setSavedMarkdown] = useState(sampleMarkdown);
const [exportConfig, setExportConfig] =
useState<ExportConfig>(loadExportConfig);
const [settingsOpen, setSettingsOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [themeRefreshKey, setThemeRefreshKey] = useState(0);
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 markdownFileInputRef = useRef<HTMLInputElement>(null);
const moreMenuRef = useRef<HTMLDivElement>(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 documentDirty = markdown !== savedMarkdown;
const documentDirtyRef = useRef(documentDirty);
const themeRefreshTargetRef = useRef<number | undefined>(undefined);
documentDirtyRef.current = documentDirty;
useEffect(() => {
const bridge = window.mdToPdfDesktop;
if (!bridge) {
return;
}
let active = true;
const consumeOpenedMarkdown = async () => {
if (
documentDirtyRef.current &&
!window.confirm("当前文档有未保存修改,是否放弃并打开其他文件?")
) {
try {
await bridge.discardPendingMarkdown();
} catch (reason) {
if (active) {
setAppError(
reason instanceof Error
? reason.message
: "无法丢弃待打开的 Markdown 文件"
);
}
}
return;
}
try {
const opened = await bridge.consumePendingMarkdown();
if (!active || !opened) {
return;
}
setAppError("");
setMarkdown(opened.markdown);
setFileName(opened.fileName);
setDocumentKind("file");
setSavedMarkdown(opened.markdown);
setStatus("Markdown 与同目录图片素材已载入");
} catch (reason) {
if (active) {
setAppError(
reason instanceof Error
? reason.message
: "无法打开 Markdown 文件"
);
}
}
};
const removeListener = bridge.onMarkdownOpened(() => {
void consumeOpenedMarkdown();
});
void consumeOpenedMarkdown();
return () => {
active = false;
removeListener();
};
}, []);
useEffect(() => {
if (!moreMenuOpen) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (
event.target instanceof Node &&
!moreMenuRef.current?.contains(event.target)
) {
setMoreMenuOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setMoreMenuOpen(false);
}
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, [moreMenuOpen]);
const handleThemesLoaded = useCallback(
(availableThemes: ThemeSummary[], loadedRefreshKey: number) => {
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;
});
if (themeRefreshTargetRef.current === loadedRefreshKey) {
themeRefreshTargetRef.current = undefined;
setStatus("主题清单已刷新");
}
},
[]
);
const handleRenderStart = useCallback(() => {
setEChartsError("");
setMermaidError("");
}, []);
const { error: renderError, result } = useMarkdownRender(
markdown,
"zh-CN",
noImageResources,
{
onRenderStart: handleRenderStart,
onStatusChange: setStatus
}
);
const {
error: themeError,
themeCss,
themes
} = useThemeResources(
exportConfig.themeId,
handleThemesLoaded,
themeRefreshKey
);
const error =
renderError ||
themeError ||
appError ||
echartsError ||
mermaidError ||
paginationError ||
pdfError;
const themeId = exportConfig.themeId;
const selectedTheme = themes.find((theme) => theme.id === themeId);
const effectiveFileName =
documentKind === "new" || !fileName
? createMarkdownFileName(markdown)
: fileName;
const documentDisplayName =
documentKind === "new" ? "新建文档" : fileName;
const showUnsavedState = documentKind === "new" || documentDirty;
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: effectiveFileName,
themeCss,
exportConfig
})
: undefined,
[effectiveFileName, exportConfig, result, themeCss]
);
const pdfRequest = useMemo<PdfExportRequest>(
() => ({
markdown,
fileName: effectiveFileName,
language: "zh-CN",
resources: noImageResources,
exportConfig
}),
[effectiveFileName, exportConfig, 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);
}, []);
function confirmDiscardChanges() {
return (
!documentDirty ||
window.confirm("当前文档有未保存修改,是否放弃这些修改?")
);
}
function handleOpenMarkdown() {
if (!confirmDiscardChanges()) {
return;
}
if (window.mdToPdfDesktop) {
void handleDesktopOpenMarkdown();
return;
}
markdownFileInputRef.current?.click();
}
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
return;
}
try {
setAppError("");
const content = await file.text();
setMarkdown(content);
setFileName(file.name);
setDocumentKind("file");
setSavedMarkdown(content);
setStatus("Markdown 已载入;Web 端不读取本地相对图片");
} catch {
setAppError("无法读取所选 Markdown 文件");
} finally {
event.target.value = "";
}
}
async function handleDesktopOpenMarkdown() {
try {
setAppError("");
const opened = await window.mdToPdfDesktop?.openMarkdown();
if (!opened) {
return;
}
setMarkdown(opened.markdown);
setFileName(opened.fileName);
setDocumentKind("file");
setSavedMarkdown(opened.markdown);
setStatus("Markdown 与同目录图片素材已载入");
} catch (reason) {
setAppError(
reason instanceof Error ? reason.message : "无法打开 Markdown 文件"
);
}
}
async function handleNewMarkdown() {
setMoreMenuOpen(false);
if (!confirmDiscardChanges()) {
return;
}
try {
setAppError("");
await window.mdToPdfDesktop?.startNewMarkdown();
setMarkdown("");
setFileName("");
setDocumentKind("new");
setSavedMarkdown("");
setStatus("新建文档尚未保存,可直接编辑或导出 PDF");
window.requestAnimationFrame(() => editorRef.current?.focus());
} catch (reason) {
setAppError(
reason instanceof Error ? reason.message : "无法新建 Markdown"
);
}
}
async function handleSaveMarkdown() {
setMoreMenuOpen(false);
const suggestedName = ensureMarkdownFileName(fileName, markdown);
try {
setAppError("");
const bridge = window.mdToPdfDesktop;
let savedFileName = suggestedName;
if (bridge) {
const saved = await bridge.saveMarkdown(suggestedName, markdown);
if (!saved) {
setStatus("已取消 Markdown 保存");
return;
}
savedFileName = saved.fileName;
} else {
downloadMarkdown(markdown, suggestedName);
}
setFileName(savedFileName);
setDocumentKind("file");
setSavedMarkdown(markdown);
setStatus(`Markdown 已保存:${savedFileName}`);
} catch (reason) {
setAppError(
reason instanceof Error ? reason.message : "Markdown 保存失败"
);
}
}
async function handleOpenThemeDirectory() {
setMoreMenuOpen(false);
try {
setAppError("");
const themeDirectory =
await window.mdToPdfDesktop?.openThemeDirectory();
if (themeDirectory) {
setStatus(`自定义主题目录:${themeDirectory}`);
}
} catch (reason) {
setAppError(
reason instanceof Error
? reason.message
: "无法打开自定义主题目录"
);
}
}
async function handleRefreshThemes() {
setMoreMenuOpen(false);
try {
setAppError("");
await window.mdToPdfDesktop?.refreshThemes();
setStatus("正在刷新主题清单…");
setThemeRefreshKey((key) => {
const nextKey = key + 1;
themeRefreshTargetRef.current = nextKey;
return nextKey;
});
} catch (reason) {
setAppError(
reason instanceof Error ? reason.message : "无法刷新主题清单"
);
}
}
async function generatePdf(download: boolean) {
if (exportingPdf) {
return;
}
setExportingPdf(true);
setPdfError("");
setStatus(
download ? "正在生成 PDF…" : "正在生成精确预览…"
);
const requestedKey = pdfCacheKey;
try {
const resolved = await getOrRequestPdfExport(
pdfRequest,
pdfCache,
previewPayload && window.mdToPdfDesktop
? () => requestDesktopPdfExport(previewPayload)
: undefined
);
if (pdfRequestKeyRef.current !== requestedKey) {
setStatus("文档已更新,已丢弃过期 PDF");
return;
}
setPdfCache(resolved);
const exported = resolved.result;
if (download) {
const saved = await downloadPdf(exported.blob, exported.fileName);
if (!saved) {
setStatus("已取消 PDF 保存");
return;
}
}
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>
<div className="topbar-brand-title">
<h1>Markdown PDF 导出器</h1>
<span className="app-version">{APP_VERSION_LABEL}</span>
</div>
</div>
<div className="document-summary">
<span className="panel-kicker">源文件</span>
<div className="document-name-row">
<strong title={documentDisplayName}>
{documentDisplayName}
</strong>
{showUnsavedState ? (
<span className="unsaved-badge">未保存</span>
) : null}
</div>
<span
className={`render-status${error ? " is-error" : ""}`}
aria-live="polite"
title={error || status}
>
{error || status}
</span>
</div>
</div>
<div className="topbar-actions">
<button type="button" onClick={handleOpenMarkdown}>
打开 Markdown
</button>
<div className="more-menu" ref={moreMenuRef}>
<button
type="button"
aria-controls="document-actions-menu"
aria-expanded={moreMenuOpen}
aria-haspopup="menu"
onClick={() => setMoreMenuOpen((open) => !open)}
>
更多
</button>
{moreMenuOpen ? (
<div
id="document-actions-menu"
className="more-menu-popover"
role="menu"
>
<button
type="button"
role="menuitem"
onClick={() => void handleNewMarkdown()}
>
新建 Markdown
</button>
<button
type="button"
role="menuitem"
onClick={() => void handleSaveMarkdown()}
>
保存 Markdown
</button>
{window.mdToPdfDesktop ? (
<>
<button
type="button"
role="menuitem"
onClick={() => void handleOpenThemeDirectory()}
>
打开自定义主题目录
</button>
<button
type="button"
role="menuitem"
onClick={() => void handleRefreshThemes()}
>
刷新主题
</button>
</>
) : null}
</div>
) : null}
</div>
<input
ref={markdownFileInputRef}
hidden
type="file"
accept=".md,.markdown,text/markdown"
onChange={handleFileChange}
/>
<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>
);
}