feat(web): export notes as Markdown

This commit is contained in:
tianma-if committed 2026-07-27 14:15:30 +08:00
1 parent 4bcee3142d
commit 24c4ab0877
5 files changed
+90

No files matched your search

+35
View File
@@ -31,6 +31,7 @@ import {
CircleAlert,
LoaderCircle,
Info,
FileDown,
Printer,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -98,6 +99,7 @@ import { ThemeBlock } from "./ThemeBlock";
import { SystemInfoDialog } from "./SystemInfoDialog";
import { fetchLatestRelease, isVersionOutdated } from "@/lib/version-check";
import { RELEASE_STATUS_EVENT } from "@/lib/release-notice";
import { downloadMarkdownFile } from "@/lib/note-markdown-export";
import { openNotePrintPreview, serializeNoteDocumentForPrint } from "@/lib/note-print";
const SUPPORTED_PASTE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]);
@@ -2052,6 +2054,32 @@ const RichEditorPane = ({
useMobilePlainTextEditor,
]);
const handleExportMarkdown = useCallback(() => {
if (!isEditorReady(editor) || !memo) {
return;
}
const markdown = useMobilePlainTextEditor
? getMobilePlainTextValue()
: useMarkdownSourceEditor
? markdownSource
: docToMarkdown(editor.getJSON() as TiptapDoc);
downloadMarkdownFile(
markdown,
title,
t("common.untitledMemo")
);
}, [
editor,
getMobilePlainTextValue,
markdownSource,
memo,
t,
title,
useMarkdownSourceEditor,
useMobilePlainTextEditor,
]);
useEffect(() => {
if (!useMobilePlainTextEditor) {
return;
@@ -2805,6 +2833,13 @@ const RichEditorPane = ({
<History className="h-4 w-4 text-slate-500" />
{t("editor.versionHistory")}
</DropdownMenuItem>
<DropdownMenuItem
className="flex h-9 w-full items-center gap-2 px-3 text-left text-sm text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
onClick={handleExportMarkdown}
>
<FileDown className="h-4 w-4 text-slate-500" />
{t("editor.exportMarkdown")}
</DropdownMenuItem>
<DropdownMenuItem
className="flex h-9 w-full items-center gap-2 px-3 text-left text-sm text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
onClick={handleExportPdf}
+31
View File
@@ -0,0 +1,31 @@
const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
export const buildMarkdownFilename = (title: string, fallback: string) => {
const sanitized = title
.replace(/[\u0000-\u001f<>:"/\\|?*]/g, "-")
.replace(/\s+/g, " ")
.replace(/[. ]+$/g, "")
.trim()
.slice(0, 100);
const basename = sanitized || fallback;
const safeBasename = WINDOWS_RESERVED_NAME.test(basename) ? `_${basename}` : basename;
return /\.md$/i.test(safeBasename) ? safeBasename : `${safeBasename}.md`;
};
export const createMarkdownFile = (markdown: string, title: string, fallback: string) => ({
blob: new Blob([markdown], { type: "text/markdown;charset=utf-8" }),
filename: buildMarkdownFilename(title, fallback),
});
export const downloadMarkdownFile = (markdown: string, title: string, fallback: string) => {
const { blob, filename } = createMarkdownFile(markdown, title, fallback);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.hidden = true;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
};
+1
View File
@@ -576,6 +576,7 @@ export const enUS = {
searchCurrentMemo: "Search current note",
replaceCurrentMemo: "Replace in current note",
versionHistory: "Version history",
exportMarkdown: "Export Markdown",
exportPdf: "Export PDF",
pdfExport: {
preparing: "Preparing print preview…",
+1
View File
@@ -576,6 +576,7 @@ export const zhCN = {
searchCurrentMemo: "搜索当前笔记",
replaceCurrentMemo: "替换当前笔记",
versionHistory: "版本历史",
exportMarkdown: "导出 Markdown",
exportPdf: "导出 PDF",
pdfExport: {
preparing: "正在生成打印预览…",
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import {
buildMarkdownFilename,
createMarkdownFile,
} from "../apps/web/src/lib/note-markdown-export";
describe("single-note Markdown export", () => {
test("creates a UTF-8 Markdown file with the current content", async () => {
const file = createMarkdownFile("# 标题\n\n当前未保存内容", "项目 / 计划", "Untitled note");
expect(file.filename).toBe("项目 - 计划.md");
expect(file.blob.type).toBe("text/markdown;charset=utf-8");
expect(await file.blob.text()).toBe("# 标题\n\n当前未保存内容");
});
test("sanitizes unsafe and reserved filenames", () => {
expect(buildMarkdownFilename("CON", "Untitled note")).toBe("_CON.md");
expect(buildMarkdownFilename('<>:"/\\|?*', "Untitled note")).toBe("---------.md");
expect(buildMarkdownFilename("...", "Untitled note")).toBe("Untitled note.md");
expect(buildMarkdownFilename("README.md", "Untitled note")).toBe("README.md");
});
});