diff --git a/apps/web/src/components/MemoListPane.tsx b/apps/web/src/components/MemoListPane.tsx index f53e9f30..5ceffab9 100644 --- a/apps/web/src/components/MemoListPane.tsx +++ b/apps/web/src/components/MemoListPane.tsx @@ -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(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) => + Array.from(event.dataTransfer.types).includes("Files"); + + const handleFileDragEnter = (event: ReactDragEvent) => { + if (!canCreateMemo || view === "trash" || !hasFileDrag(event)) return; + event.preventDefault(); + setFileDragActive(true); + }; + + const handleFileDragOver = (event: ReactDragEvent) => { + if (!canCreateMemo || view === "trash" || !hasFileDrag(event)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + if (!fileDragActive) setFileDragActive(true); + }; + + const handleFileDragLeave = (event: ReactDragEvent) => { + if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return; + setFileDragActive(false); + }; + + const handleFileDrop = (event: ReactDragEvent) => { + 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 && ( +
+
+ +
{t("memoList.dropMarkdownTitle")}
+
{t("memoList.dropMarkdownDescription")}
+
+
+ )}
{selectionMode ? (
diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index d18e882c..f3af17e9 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -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} diff --git a/apps/web/src/lib/markdown-file-import.test.mjs b/apps/web/src/lib/markdown-file-import.test.mjs new file mode 100644 index 00000000..af4d0cee --- /dev/null +++ b/apps/web/src/lib/markdown-file-import.test.mjs @@ -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.", + }); + }); +}); diff --git a/apps/web/src/lib/markdown-file-import.ts b/apps/web/src/lib/markdown-file-import.ts new file mode 100644 index 00000000..1ac50b67 --- /dev/null +++ b/apps/web/src/lib/markdown-file-import.ts @@ -0,0 +1,15 @@ +const MARKDOWN_FILE_EXTENSION_PATTERN = /\.(?:md|markdown)$/i; +const MAX_MEMO_TITLE_LENGTH = 160; + +export const isMarkdownFile = (file: Pick) => + 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) => ({ + title: getMarkdownFileTitle(file.name), + contentMarkdown: (await file.text()).replace(/^\uFEFF/, ""), +}); diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index d421bfbd..00be90a5 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -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", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 07621c5f..311dfa3f 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -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: "取消置顶",