feat: import markdown notes by drag and drop
This commit is contained in:
1 parent
b82719abdb
commit
cf5cae5251
6 files changed
+146
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/, ""),
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "取消置顶",
|
||||
|
||||
Reference in new issue
Block a user