Files
MorphDoc/apps/web/src/App.tsx
T

1946 lines
58 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 type { EditorView } from "@codemirror/view";
import {
createPagedDocumentPayload,
getPaperDimensionsMm,
millimetersToCssPixels,
type DocxCapability,
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 "@md-to-pdf/preview-engine";
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 {
APP_DISPLAY_NAME,
APP_SLOGAN
} from "./app-brand";
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 { resolveWebDocumentLinkAction } from "./document-link";
import {
type ThemeSummary,
useThemeResources
} from "./use-theme-resources";
import { sampleMarkdown } from "./sample-markdown";
import { groupThemesByCategory } from "./theme-groups";
import { getBuiltinThemeSample } from "./builtin-theme-samples";
import {
builtinTutorials,
type BuiltinTutorial
} from "./builtin-tutorials";
import { DocumentActionDialog } from "./DocumentActionDialog";
import { getDocumentShortcut } from "./document-shortcuts";
import {
applyThemeDefaults,
selectTheme
} from "./theme-margins";
import {
MarkdownEditor,
type MarkdownEditorHandle
} from "./MarkdownEditor";
import { MarkdownToolbar } from "./MarkdownToolbar";
import {
createDocxDiagnosticsMessage,
downloadDocx,
getDocxCapability,
requestDesktopDocxExport,
requestDocxExport
} from "./docx-export";
import {
ExportMenu,
type ExportCommand
} from "./ExportMenu";
import {
ExportToast,
type ExportToastState
} from "./ExportToast";
const PrecisePdfPreview = lazy(async () => {
const module = await import("./PrecisePdfPreview");
return { default: module.PrecisePdfPreview };
});
type PreviewMode = "quick" | "precise" | "continuous";
type DocumentKind = "sample" | "new" | "file";
type PendingDocumentAction =
| {
type: "new-current";
}
| {
type: "close";
systemRequest: boolean;
};
type DocumentDialogState =
| {
type: "new-location";
}
| {
type: "unsaved";
action: PendingDocumentAction;
};
const noImageResources: never[] = [];
const startsWithBlankMarkdown =
Boolean(window.mdToPdfDesktop) &&
new URLSearchParams(window.location.search).get("new") === "1";
const initialMarkdown = startsWithBlankMarkdown ? "" : sampleMarkdown;
const initialFileName = startsWithBlankMarkdown ? "" : "示例文档.md";
const initialDocumentKind: DocumentKind = startsWithBlankMarkdown
? "new"
: "sample";
export function App() {
const [markdown, setMarkdown] = useState(initialMarkdown);
const [editorDocumentVersion, setEditorDocumentVersion] =
useState(0);
const [editorView, setEditorView] = useState<EditorView | null>(
null
);
const [fileName, setFileName] = useState(initialFileName);
const [documentKind, setDocumentKind] =
useState<DocumentKind>(initialDocumentKind);
const [savedMarkdown, setSavedMarkdown] = useState(initialMarkdown);
const [exportConfig, setExportConfig] =
useState<ExportConfig>(loadExportConfig);
const [settingsOpen, setSettingsOpen] = useState(false);
const [documentDialog, setDocumentDialog] =
useState<DocumentDialogState>();
const [documentActionBusy, setDocumentActionBusy] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [moreSubmenuOpen, setMoreSubmenuOpen] = useState<
"tutorials" | "samples" | undefined
>();
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 [exportingDocx, setExportingDocx] = useState(false);
const [docxCapability, setDocxCapability] =
useState<DocxCapability>();
const [docxCapabilityError, setDocxCapabilityError] = useState("");
const [exportToast, setExportToast] =
useState<ExportToastState>();
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<MarkdownEditorHandle>(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;
const dismissExportToast = useCallback(
() => setExportToast(undefined),
[]
);
function replaceMarkdownDocument(content: string) {
setMarkdown(content);
setEditorDocumentVersion((version) => version + 1);
}
useEffect(() => {
const bridge = window.mdToPdfDesktop;
if (!bridge) {
return;
}
let active = true;
const consumeOpenedMarkdown = async (
reason: "open" | "reload" | "replace" = "open"
) => {
if (
reason === "open" &&
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("");
replaceMarkdownDocument(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((reason) => {
void consumeOpenedMarkdown(reason);
});
void consumeOpenedMarkdown();
return () => {
active = false;
removeListener();
};
}, []);
useEffect(() => {
const bridge = window.mdToPdfDesktop;
if (!bridge) {
return;
}
void bridge.setDocumentDirty(documentDirty).catch(
(reason: unknown) => {
console.warn("无法同步桌面文档修改状态", reason);
}
);
}, [documentDirty]);
useEffect(() => {
let active = true;
void getDocxCapability()
.then((capability) => {
if (!active) {
return;
}
setDocxCapability(capability);
setDocxCapabilityError("");
})
.catch((reason: unknown) => {
if (!active) {
return;
}
setDocxCapabilityError(
reason instanceof Error
? reason.message
: "无法检测 DOCX 导出能力"
);
});
return () => {
active = false;
};
}, []);
useEffect(() => {
const bridge = window.mdToPdfDesktop;
if (!bridge) {
return;
}
return bridge.onWindowCloseRequested(() => {
requestDocumentClose(true);
});
}, [documentDialog, documentDirty]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const shortcut = getDocumentShortcut(
event,
Boolean(window.mdToPdfDesktop)
);
if (!shortcut) {
return;
}
event.preventDefault();
if (documentDialog) {
return;
}
if (shortcut === "save") {
void handleSaveMarkdown();
} else if (shortcut === "save-as") {
void handleSaveMarkdownAs();
} else if (shortcut === "new") {
requestNewMarkdown();
} else {
requestDocumentClose(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [documentDialog, documentDirty, fileName, markdown]);
useEffect(() => {
if (!moreMenuOpen) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (!(event.target instanceof Node)) {
return;
}
if (!moreMenuRef.current?.contains(event.target)) {
setMoreMenuOpen(false);
setMoreSubmenuOpen(undefined);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setMoreMenuOpen(false);
setMoreSubmenuOpen(undefined);
}
};
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) => {
const currentTheme = availableThemes.find(
(theme) => theme.id === currentConfig.themeId
);
if (currentTheme) {
return applyThemeDefaults(currentConfig, currentTheme);
}
const preferredTheme =
availableThemes.find((theme) => theme.id === "typora-github") ??
availableThemes.find((theme) => theme.id === "typora-like") ??
availableThemes[0];
return preferredTheme
? selectTheme(currentConfig, preferredTheme)
: 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 themeGroups = useMemo(
() => groupThemesByCategory(themes),
[themes]
);
const sampleThemeGroups = useMemo(
() =>
groupThemesByCategory(
themes.filter((theme) => getBuiltinThemeSample(theme.id))
),
[themes]
);
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.type === "link") {
handlePagedPreviewLink(event.data.href);
return;
}
if (event.data.requestId !== previewRequestIdRef.current) {
return;
}
if (event.data.type === "rendering") {
setPaginationError("");
setEChartsError("");
setMermaidError("");
setQuickPreviewContentHeight(undefined);
setPreviewPagination({ current: 0, total: 0 });
setStatus(
event.data.layout === "continuous"
? "正在更新连续预览…"
: "正在分页…"
);
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.layout === "continuous"
? "连续预览已更新"
: `预览已分页(${event.data.pageCount} 页)`
);
setQuickPreviewContentHeight(event.data.contentHeight);
setPreviewPagination(
event.data.layout === "continuous"
? { current: 0, total: 0 }
: {
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,
previewMode === "continuous"
? "continuous"
: "paged"
),
window.location.origin
);
}, [previewFrameReady, previewMode, 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 handlePagedPreviewLink(href: string) {
const action = resolveWebDocumentLinkAction(href);
if (action.type !== "anchor" && window.mdToPdfDesktop) {
void window.mdToPdfDesktop.openDocumentLink(href).catch(
(reason: unknown) => {
setAppError(
reason instanceof Error
? reason.message
: "无法打开文档链接"
);
}
);
return;
}
if (action.type === "open") {
window.open(
action.href,
"_blank",
"noopener,noreferrer"
);
return;
}
if (action.type !== "anchor") {
return;
}
const frame = previewFrameRef.current;
const frameDocument = frame?.contentDocument;
const scrollElement = getPreviewScroller();
if (!frame || !frameDocument || !scrollElement) {
return;
}
const rawAnchorId = action.href.slice(1);
const anchorIds = [rawAnchorId];
try {
const decodedAnchorId = decodeURIComponent(rawAnchorId);
if (decodedAnchorId !== rawAnchorId) {
anchorIds.push(decodedAnchorId);
}
} catch {
// 保留原始片段继续查找,兼容不完整的百分号编码。
}
const target = anchorIds
.map(
(anchorId) =>
frameDocument.getElementById(anchorId) ??
frameDocument.getElementsByName(anchorId).item(0)
)
.find((element) => Boolean(element));
if (!target) {
return;
}
const scrollerViewportTop =
scrollElement === document.scrollingElement
? 0
: scrollElement.getBoundingClientRect().top;
const frameRect = frame.getBoundingClientRect();
const frameScale =
frame.offsetWidth > 0
? frameRect.width / frame.offsetWidth
: 1;
const targetTop = getPreviewPageScrollTop(
scrollElement.scrollTop,
frameRect.top +
target.getBoundingClientRect().top * frameScale,
scrollerViewportTop
);
scrollElement.scrollTo({ top: Math.max(0, targetTop) });
window.requestAnimationFrame(updatePreviewCurrentPage);
}
function synchronizeScroll(origin: "editor" | "preview") {
const source =
origin === "editor"
? editorRef.current?.getScrollElement()
: getPreviewScroller();
const target =
origin === "editor"
? getPreviewScroller()
: editorRef.current?.getScrollElement();
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;
}
if (previewModeRef.current === "continuous") {
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();
replaceMarkdownDocument(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;
}
replaceMarkdownDocument(opened.markdown);
setFileName(opened.fileName);
setDocumentKind("file");
setSavedMarkdown(opened.markdown);
setStatus("Markdown 与同目录图片素材已载入");
} catch (reason) {
setAppError(
reason instanceof Error ? reason.message : "无法打开 Markdown 文件"
);
}
}
function requestNewMarkdown() {
setMoreMenuOpen(false);
setMoreSubmenuOpen(undefined);
if (window.mdToPdfDesktop) {
setDocumentDialog({ type: "new-location" });
return;
}
requestCurrentWindowNew();
}
function requestCurrentWindowNew() {
if (documentDirty) {
setDocumentDialog({
type: "unsaved",
action: { type: "new-current" }
});
return;
}
setDocumentDialog(undefined);
void createMarkdownInCurrentWindow();
}
async function createMarkdownInCurrentWindow() {
try {
setAppError("");
await window.mdToPdfDesktop?.startNewMarkdown();
replaceMarkdownDocument("");
setFileName("");
setDocumentKind("new");
setSavedMarkdown("");
setStatus("新建文档尚未保存,可直接编辑或导出 PDF");
window.requestAnimationFrame(() => editorRef.current?.focus());
} catch (reason) {
setAppError(
reason instanceof Error ? reason.message : "无法新建 Markdown"
);
}
}
async function createMarkdownInNewWindow() {
setDocumentDialog(undefined);
try {
setAppError("");
await window.mdToPdfDesktop?.createNewWindow();
setStatus("已在新窗口创建空白 Markdown");
} catch (reason) {
setAppError(
reason instanceof Error
? reason.message
: "无法创建新的 Markdown 窗口"
);
}
}
function requestDocumentClose(systemRequest: boolean) {
const bridge = window.mdToPdfDesktop;
if (!bridge) {
return;
}
if (documentDialog) {
if (systemRequest) {
void bridge.resolveWindowClose(false);
}
return;
}
if (documentDirty) {
setDocumentDialog({
type: "unsaved",
action: {
type: "close",
systemRequest
}
});
return;
}
void bridge.resolveWindowClose(true);
}
async function completePendingDocumentAction(
action: PendingDocumentAction
) {
setDocumentDialog(undefined);
if (action.type === "new-current") {
await createMarkdownInCurrentWindow();
return;
}
await window.mdToPdfDesktop?.resolveWindowClose(true);
}
function cancelDocumentDialog() {
const currentDialog = documentDialog;
setDocumentDialog(undefined);
if (
currentDialog?.type === "unsaved" &&
currentDialog.action.type === "close" &&
currentDialog.action.systemRequest
) {
void window.mdToPdfDesktop?.resolveWindowClose(false);
}
}
function discardPendingDocumentChanges() {
if (documentDialog?.type !== "unsaved") {
return;
}
void completePendingDocumentAction(documentDialog.action);
}
async function saveBeforePendingDocumentAction() {
if (documentDialog?.type !== "unsaved" || documentActionBusy) {
return;
}
const action = documentDialog.action;
setDocumentActionBusy(true);
try {
if (await saveMarkdown()) {
await completePendingDocumentAction(action);
}
} finally {
setDocumentActionBusy(false);
}
}
async function handleOpenThemeSample(theme: ThemeSummary) {
const sample = getBuiltinThemeSample(theme.id);
if (!sample) {
setAppError(`找不到“${theme.name}”的内置示例`);
return;
}
setMoreMenuOpen(false);
setMoreSubmenuOpen(undefined);
if (!confirmDiscardChanges()) {
return;
}
try {
setAppError("");
await window.mdToPdfDesktop?.startNewMarkdown();
replaceMarkdownDocument(sample.markdown);
setFileName(sample.fileName);
setDocumentKind("sample");
setSavedMarkdown(sample.markdown);
setExportConfig((currentConfig) =>
selectTheme(
{
...currentConfig,
paper: {
...currentConfig.paper,
marginMode: "theme"
},
pageDecorationsMode: "theme"
},
theme
)
);
setStatus(
`已打开“${theme.name}”主题示例,可编辑后另存为 Markdown`
);
window.requestAnimationFrame(() => editorRef.current?.focus());
} catch (reason) {
setAppError(
reason instanceof Error ? reason.message : "无法打开主题示例"
);
}
}
async function handleOpenTutorial(tutorial: BuiltinTutorial) {
const theme = themes.find(
(availableTheme) => availableTheme.id === tutorial.themeId
);
if (!theme) {
setAppError(`找不到教程需要的主题:${tutorial.themeId}`);
return;
}
setMoreMenuOpen(false);
setMoreSubmenuOpen(undefined);
if (!confirmDiscardChanges()) {
return;
}
try {
setAppError("");
await window.mdToPdfDesktop?.startNewMarkdown();
replaceMarkdownDocument(tutorial.markdown);
setFileName(tutorial.fileName);
setDocumentKind("sample");
setSavedMarkdown(tutorial.markdown);
setExportConfig((currentConfig) =>
selectTheme(
{
...currentConfig,
paper: {
...currentConfig.paper,
marginMode: "theme"
},
pageDecorationsMode: "theme"
},
theme
)
);
setStatus(
`已打开“${tutorial.name}”,可编辑后另存为 Markdown`
);
window.requestAnimationFrame(() => editorRef.current?.focus());
} catch (reason) {
setAppError(
reason instanceof Error ? reason.message : "无法打开内置教程"
);
}
}
async function saveMarkdown(forceSaveAs = false) {
const suggestedName = ensureMarkdownFileName(fileName, markdown);
try {
setAppError("");
const bridge = window.mdToPdfDesktop;
let savedFileName = suggestedName;
if (bridge) {
const saved = forceSaveAs
? await bridge.saveMarkdownAs(suggestedName, markdown)
: await bridge.saveMarkdown(suggestedName, markdown);
if (!saved) {
setStatus(
forceSaveAs
? "已取消 Markdown 另存为"
: "已取消 Markdown 保存"
);
return false;
}
savedFileName = saved.fileName;
} else {
downloadMarkdown(markdown, suggestedName);
}
setFileName(savedFileName);
setDocumentKind("file");
setSavedMarkdown(markdown);
setStatus(
forceSaveAs
? `Markdown 已另存为:${savedFileName}`
: `Markdown 已保存:${savedFileName}`
);
return true;
} catch (reason) {
setAppError(
reason instanceof Error
? reason.message
: forceSaveAs
? "Markdown 另存为失败"
: "Markdown 保存失败"
);
return false;
}
}
async function handleSaveMarkdown() {
setMoreMenuOpen(false);
setMoreSubmenuOpen(undefined);
return saveMarkdown();
}
async function handleSaveMarkdownAs() {
setMoreMenuOpen(false);
setMoreSubmenuOpen(undefined);
return saveMarkdown(true);
}
async function handleOpenThemeDirectory() {
setMoreMenuOpen(false);
setMoreSubmenuOpen(undefined);
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);
setMoreSubmenuOpen(undefined);
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("");
if (download) {
setExportToast({
kind: "progress",
title: "正在生成 PDF",
message: "正在渲染页面、图表和字体,请稍候"
});
} else {
setStatus("正在生成精确预览…");
}
const requestedKey = pdfCacheKey;
try {
const resolved = await getOrRequestPdfExport(
pdfRequest,
pdfCache,
previewPayload && window.mdToPdfDesktop
? () => requestDesktopPdfExport(previewPayload)
: undefined
);
if (pdfRequestKeyRef.current !== requestedKey) {
if (download) {
setExportToast({
kind: "error",
title: "PDF 导出已停止",
message: "文档已更新,请重新导出"
});
} else {
setStatus("文档已更新,已丢弃过期 PDF");
}
return;
}
setPdfCache(resolved);
const exported = resolved.result;
if (download) {
const saved = await downloadPdf(exported.blob, exported.fileName);
if (!saved) {
setExportToast(undefined);
return;
}
}
const pageDescription =
exported.pageCount === undefined
? ""
: `${exported.pageCount} 页)`;
const mermaidDescription =
exported.mermaidErrorCount > 0
? `${exported.mermaidErrorCount} 个 Mermaid 图表渲染失败`
: "";
const echartsDescription =
exported.echartsErrorCount > 0
? `${exported.echartsErrorCount} 个 ECharts 图表渲染失败`
: "";
const resultMessage =
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${echartsDescription}${mermaidDescription}`;
if (download) {
setExportToast({
kind: "success",
title: `PDF 已导出${pageDescription}`,
message:
echartsDescription || mermaidDescription
? `${echartsDescription}${mermaidDescription}`.replace(
/^/u,
""
)
: "文件已保存"
});
} else {
setStatus(resultMessage);
}
} catch (reason) {
const message =
reason instanceof Error ? reason.message : "PDF 导出失败";
if (download) {
setExportToast({
kind: "error",
title: "PDF 导出失败",
message
});
} else {
setPdfError(message);
setStatus("精确预览失败");
}
} finally {
setExportingPdf(false);
}
}
async function generateDocx() {
if (
exportingDocx ||
docxCapability?.status !== "available"
) {
return;
}
setExportingDocx(true);
setExportToast({
kind: "progress",
title: "正在生成 DOCX",
message: "正在转换样式并将图片和图表渲染为 PNG"
});
try {
const request = {
markdown,
fileName: effectiveFileName,
language: "zh-CN",
resources: noImageResources,
exportConfig
};
let diagnostics;
if (window.mdToPdfDesktop) {
const result = await requestDesktopDocxExport(request);
if (!result.saved) {
setExportToast(undefined);
return;
}
diagnostics = result;
} else {
const result = await requestDocxExport(request);
downloadDocx(result);
diagnostics = result;
}
setExportToast({
kind: "success",
title: "DOCX 已导出",
message: createDocxDiagnosticsMessage(diagnostics)
});
} catch (reason) {
setExportToast({
kind: "error",
title: "DOCX 导出失败",
message:
reason instanceof Error
? reason.message
: "DOCX 导出失败"
});
} finally {
setExportingDocx(false);
}
}
function handlePreviewModeChange(mode: PreviewMode) {
if (mode === previewMode) {
return;
}
const keepsFrame =
previewMode !== "precise" && mode !== "precise";
setPreviewMode(mode);
if (!keepsFrame) {
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();
}
const docxDisabledReason = !markdown.trim()
? "文档内容为空"
: docxCapabilityError
? docxCapabilityError
: !docxCapability
? "正在检测 DOCX 导出能力"
: docxCapability.status === "available"
? undefined
: docxCapability.message;
const exportBusy = exportingPdf || exportingDocx;
const exportCommands: ExportCommand[] = [
{
id: "pdf",
label: "PDF",
description: "固定版式,适合打印与归档",
disabled: exportBusy || !markdown.trim(),
disabledReason: !markdown.trim()
? "文档内容为空"
: exportBusy
? "已有导出任务正在进行"
: undefined,
busy: exportingPdf,
onSelect: () => generatePdf(true)
},
{
id: "docx",
label: "DOCX",
description: "保留样式,可在 Word 与 WPS 中编辑",
disabled: exportBusy || Boolean(docxDisabledReason),
disabledReason: exportBusy
? "已有导出任务正在进行"
: docxDisabledReason,
busy: exportingDocx,
onSelect: generateDocx
}
];
return (
<main className="workspace">
{exportToast ? (
<ExportToast
toast={exportToast}
onDismiss={dismissExportToast}
/>
) : null}
<header className="topbar">
<div className="topbar-primary">
<div className="topbar-brand">
<p className="eyebrow">{APP_SLOGAN}</p>
<div className="topbar-brand-title">
<h1>{APP_DISPLAY_NAME}</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);
setMoreSubmenuOpen(undefined);
}}
>
更多
</button>
{moreMenuOpen ? (
<div
id="document-actions-menu"
className="more-menu-popover"
role="menu"
>
<button
type="button"
className="menu-command"
role="menuitem"
onClick={requestNewMarkdown}
>
<span>新建文件</span>
{window.mdToPdfDesktop ? <kbd>Ctrl+N</kbd> : null}
</button>
<button
type="button"
className="menu-command"
role="menuitem"
onClick={() => void handleSaveMarkdown()}
>
<span>保存文件</span>
<kbd>Ctrl+S</kbd>
</button>
{window.mdToPdfDesktop ? (
<button
type="button"
className="menu-command"
role="menuitem"
onClick={() => void handleSaveMarkdownAs()}
>
<span>另存为文件</span>
<kbd>Ctrl+Shift+S</kbd>
</button>
) : null}
<div className="menu-separator" role="separator" />
<div
className="more-menu-submenu-item"
onPointerEnter={() =>
setMoreSubmenuOpen("tutorials")
}
onPointerLeave={() => setMoreSubmenuOpen(undefined)}
>
<button
type="button"
className="submenu-trigger"
role="menuitem"
aria-controls="tutorials-menu"
aria-expanded={moreSubmenuOpen === "tutorials"}
aria-haspopup="menu"
onFocus={() => setMoreSubmenuOpen("tutorials")}
onClick={() => setMoreSubmenuOpen("tutorials")}
>
<span>使用教程</span>
<span aria-hidden="true"></span>
</button>
{moreSubmenuOpen === "tutorials" ? (
<div
id="tutorials-menu"
className="more-menu-submenu"
role="menu"
aria-label="使用教程"
>
{builtinTutorials.map((tutorial) => (
<button
type="button"
role="menuitem"
key={tutorial.id}
onClick={() =>
void handleOpenTutorial(tutorial)
}
>
{tutorial.name}
</button>
))}
</div>
) : null}
</div>
<div
className="more-menu-submenu-item"
onPointerEnter={() => setMoreSubmenuOpen("samples")}
onPointerLeave={() => setMoreSubmenuOpen(undefined)}
>
<button
type="button"
className="submenu-trigger"
role="menuitem"
aria-controls="theme-samples-menu"
aria-expanded={moreSubmenuOpen === "samples"}
aria-haspopup="menu"
onFocus={() => setMoreSubmenuOpen("samples")}
onClick={() => setMoreSubmenuOpen("samples")}
>
<span>主题示例</span>
<span aria-hidden="true"></span>
</button>
{moreSubmenuOpen === "samples" ? (
<div
id="theme-samples-menu"
className="more-menu-submenu"
role="menu"
aria-label="主题示例"
>
{sampleThemeGroups.map((group) => (
<section
className="sample-menu-group"
key={group.category}
>
<span className="sample-menu-group-label">
{group.label}
</span>
{group.themes.map((theme) => (
<button
type="button"
role="menuitem"
key={theme.id}
onClick={() =>
void handleOpenThemeSample(theme)
}
>
{theme.name}
</button>
))}
</section>
))}
</div>
) : null}
</div>
{window.mdToPdfDesktop ? (
<>
<div className="menu-separator" role="separator" />
<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>
<ExportMenu commands={exportCommands} />
</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>
<MarkdownToolbar editorView={editorView} />
<MarkdownEditor
ref={editorRef}
value={markdown}
documentVersion={editorDocumentVersion}
onChange={setMarkdown}
onEditorReady={setEditorView}
onScroll={() => synchronizeScroll("editor")}
/>
</div>
{sourcePanelCollapsed ? (
<button
type="button"
className="source-panel-restore"
aria-controls="markdown-source-content"
aria-expanded="false"
onClick={() => {
setSourcePanelCollapsed(false);
window.requestAnimationFrame(() =>
editorRef.current?.focus()
);
}}
>
展开源文本
</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>
<button
type="button"
className={
previewMode === "continuous" ? "is-active" : ""
}
aria-pressed={previewMode === "continuous"}
onClick={() =>
handlePreviewModeChange("continuous")
}
>
连续
</button>
</span>
<label className="theme-control">
<span className="sr-only">预览主题</span>
<select
aria-label="预览主题"
value={themeId}
onChange={(event) => {
const nextTheme = themes.find(
(theme) => theme.id === event.target.value
);
if (nextTheme) {
setExportConfig((currentConfig) =>
selectTheme(currentConfig, nextTheme)
);
}
}}
>
{themes.length === 0 ? (
<option value={themeId}>正在加载主题…</option>
) : (
themeGroups.map((group) => (
<optgroup
key={group.category}
label={group.label}
>
{group.themes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))}
</optgroup>
))
)}
</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 !== "precise" ? (
<div
className={`paper${
previewMode === "continuous"
? " continuous-paper"
: ""
}`}
style={paperStyle}
>
{previewPayload ? (
<iframe
ref={previewFrameRef}
className="preview-frame"
scrolling="no"
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}
onExternalLink={handlePagedPreviewLink}
onNavigateToPage={handlePreviewPageChange}
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}
theme={selectedTheme}
onChange={setExportConfig}
onClose={() => setSettingsOpen(false)}
onReset={() => setExportConfig(resetExportConfig())}
/>
) : null}
{documentDialog?.type === "new-location" ? (
<DocumentActionDialog
type="new-location"
onCurrentWindow={requestCurrentWindowNew}
onNewWindow={() => void createMarkdownInNewWindow()}
onCancel={cancelDocumentDialog}
/>
) : documentDialog?.type === "unsaved" ? (
<DocumentActionDialog
type="unsaved"
busy={documentActionBusy}
onSave={() => void saveBeforePendingDocumentAction()}
onDiscard={discardPendingDocumentChanges}
onCancel={cancelDocumentDialog}
/>
) : null}
</main>
);
}