feat(web): export notes as Markdown
This commit is contained in:
1 parent
4bcee3142d
commit
24c4ab0877
5 files changed
+90
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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…",
|
||||
|
||||
@@ -576,6 +576,7 @@ export const zhCN = {
|
||||
searchCurrentMemo: "搜索当前笔记",
|
||||
replaceCurrentMemo: "替换当前笔记",
|
||||
versionHistory: "版本历史",
|
||||
exportMarkdown: "导出 Markdown",
|
||||
exportPdf: "导出 PDF",
|
||||
pdfExport: {
|
||||
preparing: "正在生成打印预览…",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user