feat: import markdown notes by drag and drop

This commit is contained in:
tianma-if committed 2026-09-09 12:11:16 +08:00
1 parent b82719abdb
commit cf5cae5251
6 files changed
+146

No files matched your search

+46
View File
@@ -6,6 +6,7 @@ import {
useRef,
useEffect,
type MouseEvent,
type DragEvent as ReactDragEvent,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
@@ -43,6 +44,7 @@ import {
Share2,
FileDown,
FileCode2,
FileUp,
Printer,
Pencil,
Copy,
@@ -383,6 +385,7 @@ export const MemoListPane = ({
onOpenSettings,
onSyncMemos,
onCreateMemo,
onImportMarkdownFiles,
isSyncingMemos,
canSyncMemos,
mobileListActionsOpen,
@@ -458,6 +461,7 @@ export const MemoListPane = ({
onOpenSettings: () => void;
onSyncMemos: () => void;
onCreateMemo: () => void;
onImportMarkdownFiles: (files: File[]) => void;
isSyncingMemos: boolean;
canSyncMemos: boolean;
mobileListActionsOpen: boolean;
@@ -480,6 +484,7 @@ export const MemoListPane = ({
const [lastSelectedMemoId, setLastSelectedMemoId] = useState<string | null>(null);
const [moveTargetNotebookId, setMoveTargetNotebookId] = useState("");
const [memoIdCopyNotice, setMemoIdCopyNotice] = useState<{ status: "copied" | "error"; id: string } | null>(null);
const [fileDragActive, setFileDragActive] = useState(false);
const filterOptions = useMemo(() => getMemoFilterOptions(t), [t]);
const memoSortOptions = useMemo(() => getMemoSortOptions(t), [t]);
@@ -548,6 +553,34 @@ export const MemoListPane = ({
? t("memoList.manualSyncing")
: t("memoList.manualSync");
const hasFileDrag = (event: ReactDragEvent<HTMLDivElement>) =>
Array.from(event.dataTransfer.types).includes("Files");
const handleFileDragEnter = (event: ReactDragEvent<HTMLDivElement>) => {
if (!canCreateMemo || view === "trash" || !hasFileDrag(event)) return;
event.preventDefault();
setFileDragActive(true);
};
const handleFileDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
if (!canCreateMemo || view === "trash" || !hasFileDrag(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
if (!fileDragActive) setFileDragActive(true);
};
const handleFileDragLeave = (event: ReactDragEvent<HTMLDivElement>) => {
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
setFileDragActive(false);
};
const handleFileDrop = (event: ReactDragEvent<HTMLDivElement>) => {
if (!canCreateMemo || view === "trash" || !hasFileDrag(event)) return;
event.preventDefault();
setFileDragActive(false);
onImportMarkdownFiles(Array.from(event.dataTransfer.files));
};
const requestContextDocumentAction = (action: MemoDocumentAction) => {
if (!memoContextMenu) {
return;
@@ -947,7 +980,20 @@ export const MemoListPane = ({
className="relative flex h-full min-h-0 flex-col outline-none"
tabIndex={0}
onKeyDown={handleListKeyDown}
onDragEnter={handleFileDragEnter}
onDragOver={handleFileDragOver}
onDragLeave={handleFileDragLeave}
onDrop={handleFileDrop}
>
{fileDragActive && (
<div className="pointer-events-none absolute inset-2 z-40 flex items-center justify-center rounded-lg border-2 border-dashed border-emerald-500 bg-emerald-50/95 p-6 text-center shadow-lg backdrop-blur-sm">
<div>
<FileUp className="mx-auto h-8 w-8 text-emerald-600" />
<div className="mt-3 text-sm font-semibold text-emerald-950">{t("memoList.dropMarkdownTitle")}</div>
<div className="mt-1 text-xs text-emerald-800">{t("memoList.dropMarkdownDescription")}</div>
</div>
</div>
)}
<header className="border-b border-slate-200 bg-slate-50 px-4 pb-2 pt-[max(0.375rem,env(safe-area-inset-bottom))] lg:bg-transparent lg:py-3 lg:pt-3">
{selectionMode ? (
<div className="mb-3 flex h-10 min-w-0 items-center gap-3 lg:hidden">
+42
View File
@@ -106,6 +106,7 @@ import { updateOfficialMarketplacePlugins } from "@/lib/plugins/plugin-updates";
import { createPublicNetworkAdapter } from "@/lib/plugins/public-network-adapter";
import { clearRendererRecoveryRequired, isRendererRecoveryRequired } from "@/lib/renderer-recovery";
import { EditorPaneErrorBoundary, EditorRecoveryPane } from "./EditorPaneErrorBoundary";
import { isMarkdownFile, readMarkdownFile } from "@/lib/markdown-file-import";
const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches;
const PULL_TO_REFRESH_TRIGGER_PX = 72;
@@ -2176,6 +2177,46 @@ export const WorkspaceApp = ({
});
};
const handleImportMarkdownFiles = async (files: File[]) => {
const targetNotebookId = createMemoNotebookId;
if (!targetNotebookId || memoView === "trash") return;
if (files.length !== 1) {
setAppNoticeDialog({
title: t("memoList.importMarkdownFailedTitle"),
description: t("memoList.importMarkdownSingleFile"),
});
return;
}
const [file] = files;
if (!isMarkdownFile(file)) {
setAppNoticeDialog({
title: t("memoList.importMarkdownFailedTitle"),
description: t("memoList.importMarkdownUnsupported"),
});
return;
}
try {
const payload = await readMarkdownFile(file);
setTemplatesOpen(false);
setMobileBottomNavActive("home");
creatingMemoSelectionRef.current = true;
await createMemoMutation.mutateAsync({
notebookId: targetNotebookId,
title: payload.title,
contentMarkdown: payload.contentMarkdown,
tags: [],
});
} catch {
setAppNoticeDialog({
title: t("memoList.importMarkdownFailedTitle"),
description: t("memoList.importMarkdownReadFailed"),
});
}
};
const handleSaveAsTemplate = async (memo: MemoDetail, name: string) => {
await saveTemplateMutation.mutateAsync({ name, memoId: memo.id });
};
@@ -3285,6 +3326,7 @@ export const WorkspaceApp = ({
onSearch={setSearch}
onCancelMobileSearch={handleCancelMobileSearch}
onCreateMemo={handleCreateMemo}
onImportMarkdownFiles={(files) => void handleImportMarkdownFiles(files)}
onClearSelection={clearMemoSelection}
onEnterSelectionMode={enterMemoSelectionMode}
onReplaceSelection={replaceMemoSelection}
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import {
getMarkdownFileTitle,
isMarkdownFile,
readMarkdownFile,
} from "./markdown-file-import.ts";
describe("Markdown file import", () => {
test("accepts Markdown extensions case-insensitively", () => {
expect(isMarkdownFile({ name: "notes.md" })).toBe(true);
expect(isMarkdownFile({ name: "notes.MARKDOWN" })).toBe(true);
expect(isMarkdownFile({ name: "notes.txt" })).toBe(false);
});
test("uses the filename without its extension as the note title", () => {
expect(getMarkdownFileTitle(" docker install - setup.md ")).toBe("docker install - setup");
expect(getMarkdownFileTitle(`${"a".repeat(200)}.md`)).toHaveLength(160);
});
test("preserves Markdown content while removing a UTF-8 BOM", async () => {
const result = await readMarkdownFile({
name: "setup.md",
text: async () => "\uFEFF# Setup\n\nKeep **all** Markdown.",
});
expect(result).toEqual({
title: "setup",
contentMarkdown: "# Setup\n\nKeep **all** Markdown.",
});
});
});
+15
View File
@@ -0,0 +1,15 @@
const MARKDOWN_FILE_EXTENSION_PATTERN = /\.(?:md|markdown)$/i;
const MAX_MEMO_TITLE_LENGTH = 160;
export const isMarkdownFile = (file: Pick<File, "name">) =>
MARKDOWN_FILE_EXTENSION_PATTERN.test(file.name.trim());
export const getMarkdownFileTitle = (fileName: string) => {
const title = fileName.trim().replace(MARKDOWN_FILE_EXTENSION_PATTERN, "").trim();
return title.slice(0, MAX_MEMO_TITLE_LENGTH);
};
export const readMarkdownFile = async (file: Pick<File, "name" | "text">) => ({
title: getMarkdownFileTitle(file.name),
contentMarkdown: (await file.text()).replace(/^\uFEFF/, ""),
});
+6
View File
@@ -1383,6 +1383,12 @@ export const enUS = {
emptyDescription: "Create a note first, then preview, search, and organize notes here.",
noFilteredDescription: "Try another filter or adjust your search keywords.",
newMemo: "New note",
dropMarkdownTitle: "Drop to create a note",
dropMarkdownDescription: "Supports one .md or .markdown file",
importMarkdownFailedTitle: "Could not import Markdown file",
importMarkdownSingleFile: "Drag in one Markdown file at a time.",
importMarkdownUnsupported: "Only .md and .markdown files are supported.",
importMarkdownReadFailed: "The file could not be read or the note could not be created. Try again.",
loadingMore: "Loading more notes",
openMemo: "Open note",
unpin: "Unpin",
+6
View File
@@ -1383,6 +1383,12 @@ export const zhCN = {
emptyDescription: "先创建一条笔记,之后可以在这里快速预览、搜索和批量整理。",
noFilteredDescription: "试试切换筛选条件,或调整搜索关键词。",
newMemo: "新建笔记",
dropMarkdownTitle: "松开以创建笔记",
dropMarkdownDescription: "支持单个 .md 或 .markdown 文件",
importMarkdownFailedTitle: "无法导入 Markdown 文件",
importMarkdownSingleFile: "请一次只拖入一个 Markdown 文件。",
importMarkdownUnsupported: "仅支持 .md 或 .markdown 文件。",
importMarkdownReadFailed: "文件读取或笔记创建失败,请重试。",
loadingMore: "正在加载更多笔记",
openMemo: "打开笔记",
unpin: "取消置顶",