diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx
index 4bcc88d4..98c06687 100644
--- a/apps/web/src/components/EditorPane.tsx
+++ b/apps/web/src/components/EditorPane.tsx
@@ -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 = ({
{t("editor.versionHistory")}
+
+
+ {t("editor.exportMarkdown")}
+
{
+ 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);
+};
diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts
index 73c13dd0..13d85b79 100644
--- a/packages/shared/src/i18n/en-US.ts
+++ b/packages/shared/src/i18n/en-US.ts
@@ -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…",
diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts
index 299e2cb5..34fe5cce 100644
--- a/packages/shared/src/i18n/zh-CN.ts
+++ b/packages/shared/src/i18n/zh-CN.ts
@@ -576,6 +576,7 @@ export const zhCN = {
searchCurrentMemo: "搜索当前笔记",
replaceCurrentMemo: "替换当前笔记",
versionHistory: "版本历史",
+ exportMarkdown: "导出 Markdown",
exportPdf: "导出 PDF",
pdfExport: {
preparing: "正在生成打印预览…",
diff --git a/tests/note-markdown-export.test.ts b/tests/note-markdown-export.test.ts
new file mode 100644
index 00000000..364d6cd6
--- /dev/null
+++ b/tests/note-markdown-export.test.ts
@@ -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");
+ });
+});