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 产物未配置代码签名。
This commit is contained in:
+296
-383
@@ -49,12 +49,15 @@ import {
|
||||
} from "./preview-zoom";
|
||||
import { useMarkdownRender } from "./use-markdown-render";
|
||||
import {
|
||||
type MarkdownImageResource
|
||||
} from "./application-backend";
|
||||
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");
|
||||
@@ -62,336 +65,20 @@ const PrecisePdfPreview = lazy(async () => {
|
||||
});
|
||||
|
||||
type PreviewMode = "quick" | "precise";
|
||||
const imageFilePattern = /\.(?:avif|gif|jpe?g|png|svg|webp)$/iu;
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
const parts: string[] = [];
|
||||
const chunkSize = 32_768;
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
parts.push(
|
||||
String.fromCharCode(...bytes.subarray(offset, offset + chunkSize))
|
||||
);
|
||||
}
|
||||
return window.btoa(parts.join(""));
|
||||
}
|
||||
|
||||
async function readImageResources(files: FileList) {
|
||||
const images = Array.from(files).filter((file) =>
|
||||
imageFilePattern.test(file.name)
|
||||
);
|
||||
return Promise.all(
|
||||
images.map(async (file): Promise<MarkdownImageResource> => ({
|
||||
path: file.webkitRelativePath || file.name,
|
||||
data: bytesToBase64(new Uint8Array(await file.arrayBuffer()))
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
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: 58mm
|
||||
option:
|
||||
xAxis:
|
||||
type: category
|
||||
data: [研发, 产品, 设计, 运营]
|
||||
yAxis:
|
||||
type: value
|
||||
series:
|
||||
- type: bar
|
||||
data: [28, 18, 15, 22]
|
||||
\`\`\`
|
||||
|
||||
### Tree 树图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 产品能力树
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: tree
|
||||
orient: LR
|
||||
label:
|
||||
position: left
|
||||
data:
|
||||
- name: 文档平台
|
||||
children:
|
||||
- name: Markdown
|
||||
children:
|
||||
- name: Mermaid
|
||||
- name: ECharts
|
||||
- name: PDF
|
||||
children:
|
||||
- name: 快速预览
|
||||
- name: 精确预览
|
||||
\`\`\`
|
||||
|
||||
### Treemap 矩形树图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 模块工作量占比
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: treemap
|
||||
roam: false
|
||||
nodeClick: false
|
||||
breadcrumb:
|
||||
show: false
|
||||
label:
|
||||
show: true
|
||||
formatter: "{b}"
|
||||
color: "#1f2937"
|
||||
fontWeight: bold
|
||||
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
||||
borderRadius: 3
|
||||
padding: [3, 6]
|
||||
itemStyle:
|
||||
borderColor: "#ffffff"
|
||||
borderWidth: 2
|
||||
gapWidth: 2
|
||||
levels:
|
||||
- itemStyle:
|
||||
borderWidth: 0
|
||||
gapWidth: 2
|
||||
- upperLabel:
|
||||
show: true
|
||||
height: 28
|
||||
color: "#1f2937"
|
||||
fontWeight: bold
|
||||
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
||||
padding: [3, 6]
|
||||
itemStyle:
|
||||
borderColor: "#ffffff"
|
||||
borderWidth: 2
|
||||
gapWidth: 2
|
||||
- label:
|
||||
show: true
|
||||
formatter: "{b}"
|
||||
color: "#1f2937"
|
||||
fontWeight: bold
|
||||
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
||||
borderRadius: 3
|
||||
padding: [3, 6]
|
||||
itemStyle:
|
||||
borderColor: "#ffffff"
|
||||
borderWidth: 2
|
||||
gapWidth: 2
|
||||
data:
|
||||
- name: 渲染
|
||||
children:
|
||||
- { name: Markdown, value: 32 }
|
||||
- { name: ECharts, value: 24 }
|
||||
- { name: Mermaid, value: 18 }
|
||||
- name: 导出
|
||||
children:
|
||||
- { name: PDF, value: 28 }
|
||||
- { name: 主题, value: 12 }
|
||||
\`\`\`
|
||||
|
||||
### Sunburst 旭日图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 文档内容层级
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: sunburst
|
||||
radius: [15%, 90%]
|
||||
data:
|
||||
- name: 技术文档
|
||||
children:
|
||||
- { name: 架构, value: 18 }
|
||||
- { name: 接口, value: 24 }
|
||||
- name: 项目文档
|
||||
children:
|
||||
- { name: 周报, value: 16 }
|
||||
- { name: 计划, value: 12 }
|
||||
\`\`\`
|
||||
|
||||
### Graph 关系图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 渲染链路关系
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: graph
|
||||
layout: circular
|
||||
roam: false
|
||||
label:
|
||||
show: true
|
||||
data:
|
||||
- { name: Markdown, symbolSize: 42 }
|
||||
- { name: HTML, symbolSize: 38 }
|
||||
- { name: 预览, symbolSize: 34 }
|
||||
- { name: PDF, symbolSize: 34 }
|
||||
links:
|
||||
- { source: Markdown, target: HTML }
|
||||
- { source: HTML, target: 预览 }
|
||||
- { source: HTML, target: PDF }
|
||||
\`\`\`
|
||||
|
||||
### Sankey 桑基图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 文档流转量
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: sankey
|
||||
data:
|
||||
- { name: Markdown }
|
||||
- { name: HTML }
|
||||
- { name: 快速预览 }
|
||||
- { name: 精确预览 }
|
||||
- { name: PDF }
|
||||
links:
|
||||
- { source: Markdown, target: HTML, value: 100 }
|
||||
- { source: HTML, target: 快速预览, value: 55 }
|
||||
- { source: HTML, target: 精确预览, value: 25 }
|
||||
- { source: HTML, target: PDF, value: 20 }
|
||||
\`\`\`
|
||||
|
||||
### Chord 和弦图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 团队协作关系
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: chord
|
||||
label:
|
||||
show: true
|
||||
data:
|
||||
- { name: 研发 }
|
||||
- { name: 产品 }
|
||||
- { name: 设计 }
|
||||
- { name: 运营 }
|
||||
links:
|
||||
- { source: 研发, target: 产品, value: 12 }
|
||||
- { source: 产品, target: 设计, value: 8 }
|
||||
- { source: 设计, target: 运营, value: 6 }
|
||||
- { source: 运营, target: 研发, value: 4 }
|
||||
\`\`\`
|
||||
|
||||
### Funnel 漏斗图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 文档发布转化
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: funnel
|
||||
sort: descending
|
||||
label:
|
||||
formatter: "{b}: {c}"
|
||||
data:
|
||||
- { name: 草稿, value: 100 }
|
||||
- { name: 评审, value: 76 }
|
||||
- { name: 定稿, value: 58 }
|
||||
- { name: 发布, value: 45 }
|
||||
\`\`\`
|
||||
|
||||
### Gauge 仪表盘
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 发布完成率
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: gauge
|
||||
min: 0
|
||||
max: 100
|
||||
progress:
|
||||
show: true
|
||||
detail:
|
||||
formatter: "{value}%"
|
||||
data:
|
||||
- { name: 完成率, value: 78 }
|
||||
\`\`\`
|
||||
|
||||
### PictorialBar 象形柱图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 各渠道文档数量
|
||||
height: 58mm
|
||||
option:
|
||||
xAxis:
|
||||
type: category
|
||||
data: [知识库, 周报, 手册, 方案]
|
||||
yAxis:
|
||||
type: value
|
||||
series:
|
||||
- type: pictorialBar
|
||||
symbol: "path://M0,10 L5,0 L10,10 Z"
|
||||
symbolRepeat: true
|
||||
symbolSize: [14, 10]
|
||||
data: [12, 20, 16, 24]
|
||||
\`\`\`
|
||||
|
||||
> 预览和 PDF 将共用同一份 HTML 与 CSS。
|
||||
`;
|
||||
type DocumentKind = "sample" | "new" | "file";
|
||||
const noImageResources: never[] = [];
|
||||
|
||||
export function App() {
|
||||
const [markdown, setMarkdown] = useState(sampleMarkdown);
|
||||
const [fileName, setFileName] = useState("示例文档.md");
|
||||
const [imageResources, setImageResources] = useState<
|
||||
MarkdownImageResource[]
|
||||
>([]);
|
||||
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("");
|
||||
@@ -412,6 +99,8 @@ export function App() {
|
||||
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);
|
||||
@@ -421,9 +110,93 @@ export function App() {
|
||||
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[]) => {
|
||||
(availableThemes: ThemeSummary[], loadedRefreshKey: number) => {
|
||||
setExportConfig((currentConfig) => {
|
||||
if (
|
||||
availableThemes.some(
|
||||
@@ -440,6 +213,10 @@ export function App() {
|
||||
? { ...currentConfig, themeId: preferredTheme.id }
|
||||
: currentConfig;
|
||||
});
|
||||
if (themeRefreshTargetRef.current === loadedRefreshKey) {
|
||||
themeRefreshTargetRef.current = undefined;
|
||||
setStatus("主题清单已刷新");
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
@@ -450,7 +227,7 @@ export function App() {
|
||||
const { error: renderError, result } = useMarkdownRender(
|
||||
markdown,
|
||||
"zh-CN",
|
||||
imageResources,
|
||||
noImageResources,
|
||||
{
|
||||
onRenderStart: handleRenderStart,
|
||||
onStatusChange: setStatus
|
||||
@@ -460,7 +237,11 @@ export function App() {
|
||||
error: themeError,
|
||||
themeCss,
|
||||
themes
|
||||
} = useThemeResources(exportConfig.themeId, handleThemesLoaded);
|
||||
} = useThemeResources(
|
||||
exportConfig.themeId,
|
||||
handleThemesLoaded,
|
||||
themeRefreshKey
|
||||
);
|
||||
const error =
|
||||
renderError ||
|
||||
themeError ||
|
||||
@@ -471,6 +252,13 @@ export function App() {
|
||||
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
|
||||
@@ -501,22 +289,22 @@ export function App() {
|
||||
result && themeCss
|
||||
? createPagedDocumentPayload({
|
||||
document: result,
|
||||
fileName,
|
||||
fileName: effectiveFileName,
|
||||
themeCss,
|
||||
exportConfig
|
||||
})
|
||||
: undefined,
|
||||
[exportConfig, fileName, result, themeCss]
|
||||
[effectiveFileName, exportConfig, result, themeCss]
|
||||
);
|
||||
const pdfRequest = useMemo<PdfExportRequest>(
|
||||
() => ({
|
||||
markdown,
|
||||
fileName,
|
||||
fileName: effectiveFileName,
|
||||
language: "zh-CN",
|
||||
resources: imageResources,
|
||||
resources: noImageResources,
|
||||
exportConfig
|
||||
}),
|
||||
[exportConfig, fileName, imageResources, markdown]
|
||||
[effectiveFileName, exportConfig, markdown]
|
||||
);
|
||||
const pdfCacheKey = useMemo(
|
||||
() => createPdfExportCacheKey(pdfRequest),
|
||||
@@ -848,6 +636,24 @@ export function App() {
|
||||
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) {
|
||||
@@ -856,10 +662,12 @@ export function App() {
|
||||
|
||||
try {
|
||||
setAppError("");
|
||||
setMarkdown(await file.text());
|
||||
const content = await file.text();
|
||||
setMarkdown(content);
|
||||
setFileName(file.name);
|
||||
setImageResources([]);
|
||||
setStatus("Markdown 已载入;如含相对图片,请添加素材目录");
|
||||
setDocumentKind("file");
|
||||
setSavedMarkdown(content);
|
||||
setStatus("Markdown 已载入;Web 端不读取本地相对图片");
|
||||
} catch {
|
||||
setAppError("无法读取所选 Markdown 文件");
|
||||
} finally {
|
||||
@@ -867,25 +675,6 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssetDirectoryChange(
|
||||
event: ChangeEvent<HTMLInputElement>
|
||||
) {
|
||||
const files = event.target.files;
|
||||
if (!files?.length) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setAppError("");
|
||||
const resources = await readImageResources(files);
|
||||
setImageResources(resources);
|
||||
setStatus(`已载入 ${resources.length} 个图片素材`);
|
||||
} catch {
|
||||
setAppError("无法读取所选图片素材目录");
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDesktopOpenMarkdown() {
|
||||
try {
|
||||
setAppError("");
|
||||
@@ -895,7 +684,8 @@ export function App() {
|
||||
}
|
||||
setMarkdown(opened.markdown);
|
||||
setFileName(opened.fileName);
|
||||
setImageResources([]);
|
||||
setDocumentKind("file");
|
||||
setSavedMarkdown(opened.markdown);
|
||||
setStatus("Markdown 与同目录图片素材已载入");
|
||||
} catch (reason) {
|
||||
setAppError(
|
||||
@@ -904,6 +694,91 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -993,7 +868,14 @@ export function App() {
|
||||
</div>
|
||||
<div className="document-summary">
|
||||
<span className="panel-kicker">源文件</span>
|
||||
<strong title={fileName}>{fileName}</strong>
|
||||
<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"
|
||||
@@ -1004,36 +886,67 @@ export function App() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
{window.mdToPdfDesktop ? (
|
||||
<button type="button" onClick={handleOpenMarkdown}>
|
||||
打开 Markdown
|
||||
</button>
|
||||
<div className="more-menu" ref={moreMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDesktopOpenMarkdown()}
|
||||
aria-controls="document-actions-menu"
|
||||
aria-expanded={moreMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setMoreMenuOpen((open) => !open)}
|
||||
>
|
||||
打开 Markdown
|
||||
更多 ▾
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<label className="file-button">
|
||||
选择 Markdown
|
||||
<input
|
||||
type="file"
|
||||
accept=".md,.markdown,text/markdown,text/plain"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</label>
|
||||
<label className="file-button">
|
||||
添加素材目录
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
// React 尚未声明 Chromium 的目录选择属性。
|
||||
{...({ webkitdirectory: "" } as Record<string, string>)}
|
||||
onChange={handleAssetDirectoryChange}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user