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"); + }); +});