feat: 发布 v0.4.0 桌面端
This commit is contained in:
+15
-3
@@ -37,9 +37,11 @@ import {
|
||||
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,
|
||||
@@ -832,7 +834,10 @@ export function App() {
|
||||
try {
|
||||
const resolved = await getOrRequestPdfExport(
|
||||
pdfRequest,
|
||||
pdfCache
|
||||
pdfCache,
|
||||
previewPayload && window.mdToPdfDesktop
|
||||
? () => requestDesktopPdfExport(previewPayload)
|
||||
: undefined
|
||||
);
|
||||
if (pdfRequestKeyRef.current !== requestedKey) {
|
||||
setStatus("文档已更新,已丢弃过期 PDF");
|
||||
@@ -841,7 +846,11 @@ export function App() {
|
||||
setPdfCache(resolved);
|
||||
const exported = resolved.result;
|
||||
if (download) {
|
||||
downloadPdf(exported.blob, exported.fileName);
|
||||
const saved = await downloadPdf(exported.blob, exported.fileName);
|
||||
if (!saved) {
|
||||
setStatus("已取消 PDF 保存");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const pageDescription =
|
||||
exported.pageCount === undefined
|
||||
@@ -893,7 +902,10 @@ export function App() {
|
||||
<div className="topbar-primary">
|
||||
<div className="topbar-brand">
|
||||
<p className="eyebrow">内网文档工具</p>
|
||||
<h1>Markdown PDF 导出器</h1>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const APP_VERSION = __APP_VERSION__;
|
||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
|
||||
|
||||
export interface ThemeSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
bundled: boolean;
|
||||
source: "bundled" | "local";
|
||||
}
|
||||
|
||||
export interface MarkdownRenderInput {
|
||||
markdown: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal) {
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
|
||||
async function readErrorMessage(
|
||||
response: Response,
|
||||
fallback: string
|
||||
) {
|
||||
try {
|
||||
const payload = (await response.json()) as { message?: unknown };
|
||||
return typeof payload.message === "string"
|
||||
? payload.message
|
||||
: fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderMarkdownDocument(
|
||||
input: MarkdownRenderInput,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
throwIfAborted(signal);
|
||||
if (window.mdToPdfDesktop) {
|
||||
const result = await window.mdToPdfDesktop.renderMarkdown(input);
|
||||
throwIfAborted(signal);
|
||||
return result;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/render", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
...(signal ? { signal } : {})
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, "渲染失败"));
|
||||
}
|
||||
return response.json() as Promise<RenderedMarkdownDocument>;
|
||||
}
|
||||
|
||||
export async function listThemes(signal?: AbortSignal) {
|
||||
throwIfAborted(signal);
|
||||
if (window.mdToPdfDesktop) {
|
||||
const result = await window.mdToPdfDesktop.listThemes();
|
||||
throwIfAborted(signal);
|
||||
return result;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/themes", {
|
||||
...(signal ? { signal } : {})
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("无法加载主题清单");
|
||||
}
|
||||
return response.json() as Promise<{ themes: ThemeSummary[] }>;
|
||||
}
|
||||
|
||||
export async function getThemeCss(
|
||||
themeId: string,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
throwIfAborted(signal);
|
||||
if (window.mdToPdfDesktop) {
|
||||
const result = await window.mdToPdfDesktop.getThemeCss(themeId);
|
||||
throwIfAborted(signal);
|
||||
return result;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/themes/${encodeURIComponent(themeId)}/css`,
|
||||
{ ...(signal ? { signal } : {}) }
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("无法加载主题");
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
@@ -87,6 +87,30 @@ window.__mdToPdfRender = async (
|
||||
};
|
||||
document.documentElement.dataset.runtimeReady = "true";
|
||||
|
||||
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
|
||||
if (desktopPdfBridge) {
|
||||
desktopPdfBridge.onRender(async (requestId, payload) => {
|
||||
const requestAnimationFrame = window.requestAnimationFrame;
|
||||
window.requestAnimationFrame = (callback) =>
|
||||
window.setTimeout(() => callback(performance.now()), 0);
|
||||
try {
|
||||
const result = await window.__mdToPdfRender?.(payload, "pdf");
|
||||
if (!result) {
|
||||
throw new Error("PDF 分页运行时不可用");
|
||||
}
|
||||
desktopPdfBridge.complete(requestId, result);
|
||||
} catch (reason: unknown) {
|
||||
desktopPdfBridge.fail(
|
||||
requestId,
|
||||
reason instanceof Error ? reason.message : "PDF 分页失败"
|
||||
);
|
||||
} finally {
|
||||
window.requestAnimationFrame = requestAnimationFrame;
|
||||
}
|
||||
});
|
||||
desktopPdfBridge.ready();
|
||||
}
|
||||
|
||||
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
if (
|
||||
event.origin !== window.location.origin ||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
import type {
|
||||
ExportConfig,
|
||||
PagedDocumentPayload
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export interface PdfExportRequest {
|
||||
markdown: string;
|
||||
@@ -129,11 +132,38 @@ export async function requestPdfExport(
|
||||
};
|
||||
}
|
||||
|
||||
export function downloadPdf(blob: Blob, fileName: string) {
|
||||
export async function requestDesktopPdfExport(
|
||||
payload: PagedDocumentPayload
|
||||
): Promise<PdfExportResult> {
|
||||
const bridge = window.mdToPdfDesktop;
|
||||
if (!bridge) {
|
||||
throw new Error("桌面 PDF 引擎不可用");
|
||||
}
|
||||
|
||||
const result = await bridge.generatePdf(payload);
|
||||
const pdfBytes = Uint8Array.from(result.pdf);
|
||||
return {
|
||||
blob: new Blob([pdfBytes.buffer], { type: "application/pdf" }),
|
||||
fileName: payload.fileName.replace(/\.(md|markdown)$/i, "") + ".pdf",
|
||||
pageCount: result.pageCount,
|
||||
echartsErrorCount: result.echartsErrors.length,
|
||||
mermaidErrorCount: result.mermaidErrors.length
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadPdf(blob: Blob, fileName: string) {
|
||||
if (window.mdToPdfDesktop) {
|
||||
return window.mdToPdfDesktop.savePdf(
|
||||
fileName,
|
||||
new Uint8Array(await blob.arrayBuffer())
|
||||
);
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,24 @@ button:disabled {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.topbar-brand-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.app-version {
|
||||
padding: 2px 7px;
|
||||
border: 1px solid #c8d5cf;
|
||||
border-radius: 999px;
|
||||
background: #edf3f0;
|
||||
color: #527061;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.035em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1029,6 +1047,12 @@ textarea:focus {
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.topbar-brand-title {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.settings-drawer {
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
|
||||
import { renderMarkdownDocument } from "./application-backend";
|
||||
|
||||
export interface MarkdownRenderCallbacks {
|
||||
onRenderStart?: () => void;
|
||||
@@ -23,27 +24,10 @@ export function useMarkdownRender(
|
||||
onRenderStart?.();
|
||||
setError("");
|
||||
|
||||
void fetch("/api/render", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ markdown, language }),
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async (response) => {
|
||||
const payload = (await response.json()) as
|
||||
| RenderedMarkdownDocument
|
||||
| { message?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
"message" in payload
|
||||
? payload.message ?? "渲染失败"
|
||||
: "渲染失败"
|
||||
);
|
||||
}
|
||||
return payload as RenderedMarkdownDocument;
|
||||
})
|
||||
void renderMarkdownDocument(
|
||||
{ markdown, language },
|
||||
controller.signal
|
||||
)
|
||||
.then((payload) => {
|
||||
setResult(payload);
|
||||
onStatusChange?.("预览已更新");
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
getThemeCss,
|
||||
listThemes,
|
||||
type ThemeSummary
|
||||
} from "./application-backend";
|
||||
|
||||
export interface ThemeSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
bundled: boolean;
|
||||
source: "bundled" | "local";
|
||||
}
|
||||
export type { ThemeSummary } from "./application-backend";
|
||||
|
||||
export function useThemeResources(
|
||||
themeId: string,
|
||||
@@ -21,15 +19,7 @@ export function useThemeResources(
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
void fetch("/api/themes", {
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("无法加载主题清单");
|
||||
}
|
||||
return response.json() as Promise<{ themes: ThemeSummary[] }>;
|
||||
})
|
||||
void listThemes(controller.signal)
|
||||
.then(({ themes: availableThemes }) => {
|
||||
setThemes(availableThemes);
|
||||
setCatalogError("");
|
||||
@@ -51,15 +41,7 @@ export function useThemeResources(
|
||||
setThemeCss("");
|
||||
setCssError("");
|
||||
|
||||
void fetch(`/api/themes/${encodeURIComponent(themeId)}/css`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("无法加载主题");
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
void getThemeCss(themeId, controller.signal)
|
||||
.then(setThemeCss)
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
|
||||
Vendored
+57
@@ -1 +1,58 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type {
|
||||
PagedDocumentPayload,
|
||||
PagedDocumentRenderResult,
|
||||
RenderedMarkdownDocument
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
declare global {
|
||||
const __APP_VERSION__: string;
|
||||
|
||||
interface DesktopPdfResult {
|
||||
pdf: Uint8Array;
|
||||
pageCount: number;
|
||||
echartsErrors: string[];
|
||||
mermaidErrors: string[];
|
||||
}
|
||||
|
||||
interface DesktopApplicationBridge {
|
||||
renderMarkdown(input: {
|
||||
markdown: string;
|
||||
language: string;
|
||||
}): Promise<RenderedMarkdownDocument>;
|
||||
listThemes(): Promise<{
|
||||
themes: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
bundled: boolean;
|
||||
source: "bundled" | "local";
|
||||
}>;
|
||||
}>;
|
||||
getThemeCss(themeId: string): Promise<string>;
|
||||
generatePdf(payload: PagedDocumentPayload): Promise<DesktopPdfResult>;
|
||||
savePdf(fileName: string, pdf: Uint8Array): Promise<boolean>;
|
||||
}
|
||||
|
||||
interface DesktopPdfRuntimeBridge {
|
||||
ready(): void;
|
||||
onRender(
|
||||
listener: (
|
||||
requestId: number,
|
||||
payload: PagedDocumentPayload
|
||||
) => Promise<void> | void
|
||||
): () => void;
|
||||
complete(
|
||||
requestId: number,
|
||||
result: PagedDocumentRenderResult
|
||||
): void;
|
||||
fail(requestId: number, message: string): void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
mdToPdfDesktop?: DesktopApplicationBridge;
|
||||
__mdToPdfDesktopPdf?: DesktopPdfRuntimeBridge;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user