mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
笔记模块重构与升级
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
FileCode,
|
||||
FileText,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import {
|
||||
buildVaultNoteMarkdownExportFiles,
|
||||
sanitizeNoteExportFileNamePart,
|
||||
type VaultNote,
|
||||
} from "../../domain/notes";
|
||||
import { copyToClipboard } from "../keychain/utils";
|
||||
import { toast } from "../ui/toast";
|
||||
import { buildTextFilesZipBlob } from "../../lib/textZip";
|
||||
|
||||
export interface NoteExportMenuProps {
|
||||
note: VaultNote | null;
|
||||
allNotes: VaultNote[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const NoteExportMenu: React.FC<NoteExportMenuProps> = ({
|
||||
note,
|
||||
allNotes,
|
||||
className = "",
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("mousedown", handleClickOutside);
|
||||
return () => window.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
const handleExportSingleMarkdown = () => {
|
||||
if (!note) return;
|
||||
const fileName = `${sanitizeNoteExportFileNamePart(note.title, "note")}.md`;
|
||||
const blob = new Blob([note.content], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t("notes.exportSuccess") || "已导出 Markdown 文件");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleExportSingleHtml = () => {
|
||||
if (!note) return;
|
||||
const title = note.title || "Untitled Note";
|
||||
const escapedTitle = title.replace(/</g, "<").replace(/>/g, ">");
|
||||
const htmlContent = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${escapedTitle}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; max-width: 800px; margin: 40px auto; padding: 0 20px; color: #333; }
|
||||
h1, h2, h3 { color: #111; }
|
||||
pre { background: #f4f4f5; padding: 16px; border-radius: 8px; overflow-x: auto; }
|
||||
code { font-family: monospace; background: #f4f4f5; padding: 2px 6px; border-radius: 4px; }
|
||||
pre code { background: none; padding: 0; }
|
||||
blockquote { border-left: 4px solid #e4e4e7; margin: 0; padding-left: 16px; color: #71717a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${escapedTitle}</h1>
|
||||
<pre style="white-space: pre-wrap;">${note.content.replace(/</g, "<").replace(/>/g, ">")}</pre>
|
||||
</body>
|
||||
</html>`;
|
||||
const fileName = `${sanitizeNoteExportFileNamePart(note.title, "note")}.html`;
|
||||
const blob = new Blob([htmlContent], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t("notes.exportSuccess") || "已导出 HTML 文件");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCopyMarkdown = async () => {
|
||||
if (!note) return;
|
||||
const ok = await copyToClipboard(note.content);
|
||||
if (ok) {
|
||||
toast.success(t("common.copied") || "已复制到剪贴板");
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleExportAllZip = () => {
|
||||
if (!allNotes.length) return;
|
||||
const files = buildVaultNoteMarkdownExportFiles(allNotes, { type: "all" });
|
||||
const zipBlob = buildTextFilesZipBlob(files);
|
||||
const url = URL.createObjectURL(zipBlob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `netcatty-notes-backup-${new Date().toISOString().slice(0, 10)}.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t("notes.exportAllSuccess") || "已导出全部笔记备份 ZIP");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative inline-block ${className}`} ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t("notes.export") || "导出与分享"}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1.5 w-56 bg-popover border border-border rounded-lg shadow-lg py-1.5 z-50 text-sm animate-in fade-in-50 zoom-in-95">
|
||||
{note && (
|
||||
<>
|
||||
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
当前笔记
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
|
||||
onClick={handleExportSingleMarkdown}
|
||||
>
|
||||
<FileText size={14} className="text-primary" />
|
||||
<span>导出为 Markdown (.md)</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
|
||||
onClick={handleExportSingleHtml}
|
||||
>
|
||||
<FileCode size={14} className="text-primary" />
|
||||
<span>导出为 HTML (.html)</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
|
||||
onClick={handleCopyMarkdown}
|
||||
>
|
||||
<Copy size={14} className="text-muted-foreground" />
|
||||
<span>复制 Markdown 源码</span>
|
||||
</button>
|
||||
<div className="my-1 border-t border-border" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
全部笔记
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
|
||||
onClick={handleExportAllZip}
|
||||
disabled={!allNotes.length}
|
||||
>
|
||||
<Download size={14} className="text-emerald-500" />
|
||||
<span>导出全部为 ZIP 压缩包</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Hash, ListTree, X } from "lucide-react";
|
||||
import React, { useMemo } from "react";
|
||||
import { extractNoteHeadings, type NoteHeadingItem } from "../../domain/notes";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
|
||||
export interface NoteOutlineProps {
|
||||
content: string;
|
||||
onSelectHeading?: (heading: NoteHeadingItem) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const NoteOutline: React.FC<NoteOutlineProps> = ({
|
||||
content,
|
||||
onSelectHeading,
|
||||
onClose,
|
||||
className = "",
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const headings = useMemo(() => extractNoteHeadings(content), [content]);
|
||||
|
||||
const getIndentClass = (level: number) => {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return "pl-2 font-semibold text-foreground";
|
||||
case 2:
|
||||
return "pl-5 text-foreground/90";
|
||||
case 3:
|
||||
return "pl-8 text-muted-foreground";
|
||||
case 4:
|
||||
default:
|
||||
return "pl-11 text-muted-foreground/80 text-xs";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col h-full bg-background/95 border-l border-border select-none ${className}`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3.5 py-2.5 border-b border-border/80 shrink-0">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-foreground uppercase tracking-wider">
|
||||
<ListTree size={14} className="text-primary" />
|
||||
<span>大纲目录 ({headings.length})</span>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={onClose}
|
||||
title={t("common.close") || "关闭"}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
|
||||
{headings.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground px-4">
|
||||
<Hash size={24} className="mx-auto mb-2 opacity-30" />
|
||||
<p>暂无标题大纲</p>
|
||||
<p className="mt-1 text-[11px] opacity-70">
|
||||
在 Markdown 中使用 #、## 编写各级标题
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
headings.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`w-full text-left py-1.5 pr-2 rounded-md hover:bg-muted/70 text-xs truncate transition-colors flex items-center gap-1.5 ${getIndentClass(
|
||||
item.level,
|
||||
)}`}
|
||||
onClick={() => onSelectHeading?.(item)}
|
||||
title={item.text}
|
||||
>
|
||||
<span className="opacity-40 text-[10px] shrink-0">H{item.level}</span>
|
||||
<span className="truncate">{item.text}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
Copy,
|
||||
Folder,
|
||||
Hash,
|
||||
MoreVertical,
|
||||
Pin,
|
||||
PinOff,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import {
|
||||
extractNoteSnippet,
|
||||
filterAndSortVaultNotes,
|
||||
getNoteGroupLeafName,
|
||||
type NoteFilterMode,
|
||||
type NoteSortOption,
|
||||
type VaultNote,
|
||||
} from "../../domain/notes";
|
||||
import type { Host } from "../../types";
|
||||
|
||||
export interface NotesListProps {
|
||||
notes: VaultNote[];
|
||||
selectedNoteId: string | null;
|
||||
selectedGroup: string | null;
|
||||
selectedTag: string | null;
|
||||
filterMode: NoteFilterMode;
|
||||
hosts?: Host[];
|
||||
onSelectNote: (noteId: string) => void;
|
||||
onCreateNote: () => void;
|
||||
onTogglePinNote: (noteId: string) => void;
|
||||
onDuplicateNote: (note: VaultNote) => void;
|
||||
onDeleteNote: (noteId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatNoteRelativeTime(timestamp: number): string {
|
||||
if (!timestamp || !Number.isFinite(timestamp)) return "";
|
||||
const diff = Date.now() - timestamp;
|
||||
const minute = 60 * 1000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
|
||||
if (diff < minute) return "刚刚";
|
||||
if (diff < hour) return `${Math.floor(diff / minute)} 分钟前`;
|
||||
if (diff < day) {
|
||||
const d = new Date(timestamp);
|
||||
return `${d.getHours().toString().padStart(2, "0")}:${d
|
||||
.getMinutes()
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
if (diff < 2 * day) return "昨天";
|
||||
if (diff < 365 * day) {
|
||||
const d = new Date(timestamp);
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`;
|
||||
}
|
||||
const d = new Date(timestamp);
|
||||
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
|
||||
export const NotesList: React.FC<NotesListProps> = ({
|
||||
notes,
|
||||
selectedNoteId,
|
||||
selectedGroup,
|
||||
selectedTag,
|
||||
filterMode,
|
||||
hosts = [],
|
||||
onSelectNote,
|
||||
onCreateNote,
|
||||
onTogglePinNote,
|
||||
onDuplicateNote,
|
||||
onDeleteNote,
|
||||
className = "",
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortOption, setSortOption] = useState<NoteSortOption>("updatedDesc");
|
||||
const [activeMenuNoteId, setActiveMenuNoteId] = useState<string | null>(null);
|
||||
|
||||
// Compute title for the list header
|
||||
const headerTitle = useMemo(() => {
|
||||
if (selectedTag) return `#${selectedTag}`;
|
||||
if (selectedGroup) return getNoteGroupLeafName(selectedGroup);
|
||||
if (filterMode === "pinned") return t("notes.pinned") || "已置顶笔记";
|
||||
if (filterMode === "recent") return t("notes.recent") || "最近修改";
|
||||
if (filterMode === "uncategorized") return t("notes.uncategorized") || "未分类笔记";
|
||||
return t("notes.allNotes") || "全部笔记";
|
||||
}, [selectedTag, selectedGroup, filterMode, t]);
|
||||
|
||||
// Filter and sort notes
|
||||
const displayedNotes = useMemo(() => {
|
||||
return filterAndSortVaultNotes(notes, {
|
||||
search: searchQuery,
|
||||
group: selectedGroup,
|
||||
tag: selectedTag,
|
||||
filterMode,
|
||||
sort: sortOption,
|
||||
hosts,
|
||||
});
|
||||
}, [notes, searchQuery, selectedGroup, selectedTag, filterMode, sortOption, hosts]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col h-full bg-background border-r border-border select-none ${className}`}
|
||||
>
|
||||
{/* List Header */}
|
||||
<div className="p-3 border-b border-border/80 space-y-2 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<h2 className="font-semibold text-sm text-foreground truncate">
|
||||
{headerTitle}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.2 rounded-full shrink-0">
|
||||
{displayedNotes.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={sortOption}
|
||||
onChange={(e) => setSortOption(e.target.value as NoteSortOption)}
|
||||
className="text-xs bg-muted/70 hover:bg-muted border border-border/60 rounded px-1.5 py-1 text-foreground cursor-pointer outline-none transition-colors"
|
||||
title="排序方式"
|
||||
>
|
||||
<option value="updatedDesc">最近修改</option>
|
||||
<option value="createdDesc">最新创建</option>
|
||||
<option value="titleAsc">标题 A-Z</option>
|
||||
<option value="titleDesc">标题 Z-A</option>
|
||||
<option value="custom">自定义顺序</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-1 bg-primary text-primary-foreground hover:bg-primary/90 text-xs font-medium rounded-md flex items-center gap-1 transition-colors shadow-sm shrink-0"
|
||||
onClick={onCreateNote}
|
||||
title={t("notes.newNote") || "新建笔记"}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>新建</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={13}
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t("notes.searchPlaceholder") || "搜索笔记标题、内容、标签..."}
|
||||
className="w-full bg-muted/50 focus:bg-background border border-border focus:border-primary/60 rounded-md pl-7 pr-7 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/70 outline-none transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards List */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1.5">
|
||||
{displayedNotes.length === 0 ? (
|
||||
<div className="py-12 text-center text-xs text-muted-foreground">
|
||||
<p>暂无匹配笔记</p>
|
||||
{searchQuery ? (
|
||||
<p className="mt-1 text-[11px] opacity-70">
|
||||
可尝试更改搜索关键词或清除搜索条件
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 px-3 py-1.5 bg-primary/10 hover:bg-primary/20 text-primary rounded text-xs transition-colors font-medium"
|
||||
onClick={onCreateNote}
|
||||
>
|
||||
创建第一篇笔记
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
displayedNotes.map((note) => {
|
||||
const isSelected = selectedNoteId === note.id;
|
||||
const snippet = extractNoteSnippet(note.content, 90);
|
||||
const title = note.title.trim() || t("notes.untitled") || "无标题笔记";
|
||||
const isMenuOpen = activeMenuNoteId === note.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={note.id}
|
||||
className={`group relative p-2.5 rounded-lg border transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-primary/10 border-primary/50 shadow-sm"
|
||||
: "bg-card hover:bg-muted/50 border-border/70 hover:border-border"
|
||||
}`}
|
||||
onClick={() => onSelectNote(note.id)}
|
||||
>
|
||||
{/* Title & Pin Indicator */}
|
||||
<div className="flex items-start justify-between gap-1 mb-1">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{note.isPinned && (
|
||||
<Pin
|
||||
size={12}
|
||||
className="text-amber-500 fill-amber-500 shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs font-semibold truncate ${
|
||||
isSelected ? "text-primary" : "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions Dropdown Trigger on hover */}
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActiveMenuNoteId(isMenuOpen ? null : note.id);
|
||||
}}
|
||||
>
|
||||
<MoreVertical size={13} />
|
||||
</button>
|
||||
|
||||
{isMenuOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 w-32 bg-popover border border-border rounded-md shadow-lg py-1 z-50 text-xs text-foreground animate-in fade-in-50 zoom-in-95"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-2.5 py-1 flex items-center gap-2 hover:bg-muted transition-colors text-left"
|
||||
onClick={() => {
|
||||
onTogglePinNote(note.id);
|
||||
setActiveMenuNoteId(null);
|
||||
}}
|
||||
>
|
||||
{note.isPinned ? (
|
||||
<>
|
||||
<PinOff size={13} className="text-muted-foreground" />
|
||||
<span>取消置顶</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pin size={13} className="text-amber-500" />
|
||||
<span>置顶笔记</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-2.5 py-1 flex items-center gap-2 hover:bg-muted transition-colors text-left"
|
||||
onClick={() => {
|
||||
onDuplicateNote(note);
|
||||
setActiveMenuNoteId(null);
|
||||
}}
|
||||
>
|
||||
<Copy size={13} className="text-muted-foreground" />
|
||||
<span>创建副本</span>
|
||||
</button>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-2.5 py-1 flex items-center gap-2 hover:bg-destructive/10 text-destructive transition-colors text-left"
|
||||
onClick={() => {
|
||||
onDeleteNote(note.id);
|
||||
setActiveMenuNoteId(null);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
<span>删除</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Snippet Preview */}
|
||||
{snippet && (
|
||||
<p className="text-[11px] text-muted-foreground line-clamp-2 leading-relaxed mb-2 font-normal">
|
||||
{snippet}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Footer Metadata: Date, Folder, Tags */}
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground/80 pt-0.5">
|
||||
<span className="shrink-0">
|
||||
{formatNoteRelativeTime(note.updatedAt)}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1 overflow-hidden ml-2">
|
||||
{note.group && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1 py-0.2 bg-muted rounded text-[10px] text-muted-foreground truncate max-w-[80px]">
|
||||
<Folder size={9} className="opacity-70 shrink-0" />
|
||||
<span className="truncate">{getNoteGroupLeafName(note.group)}</span>
|
||||
</span>
|
||||
)}
|
||||
{note.tags && note.tags.length > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1 py-0.2 bg-muted rounded text-[10px] text-muted-foreground truncate max-w-[70px]">
|
||||
<Hash size={9} className="opacity-70 shrink-0" />
|
||||
<span className="truncate">{note.tags[0]}</span>
|
||||
{note.tags.length > 1 && (
|
||||
<span className="opacity-60">+{note.tags.length - 1}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,11 +7,15 @@ import {
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Glasses,
|
||||
Hash,
|
||||
ListTree,
|
||||
MoreHorizontal,
|
||||
Minimize2,
|
||||
PencilLine,
|
||||
Pin,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -20,6 +24,8 @@ import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend } from "../../application/state/useApplicationBackend";
|
||||
import { useStoredNumber } from "../../application/state/useStoredNumber";
|
||||
import { useStoredString } from "../../application/state/useStoredString";
|
||||
import { NoteOutline } from "./NoteOutline";
|
||||
import { NoteExportMenu } from "./NoteExportMenu";
|
||||
import {
|
||||
ancestorNoteGroupPaths,
|
||||
buildVaultNoteMarkdownExportFiles,
|
||||
@@ -388,6 +394,9 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [showOutline, setShowOutline] = useState(false);
|
||||
const [tagInputOpen, setTagInputOpen] = useState(false);
|
||||
const [newTagText, setNewTagText] = useState("");
|
||||
const [treeWidth, setTreeWidth, persistTreeWidth] = useStoredNumber(
|
||||
STORAGE_KEY_VAULT_NOTES_TREE_WIDTH,
|
||||
NOTES_TREE_DEFAULT_WIDTH,
|
||||
@@ -418,6 +427,34 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
return cleaned;
|
||||
}, [onUpdateNotes]);
|
||||
|
||||
const togglePinNoteById = useCallback((noteId: string) => {
|
||||
commitNotes(sortedNotesRef.current.map((n) => {
|
||||
if (n.id !== noteId) return n;
|
||||
const isPinned = !n.isPinned;
|
||||
return { ...n, isPinned: isPinned ? true : undefined, updatedAt: Date.now() };
|
||||
}));
|
||||
}, [commitNotes]);
|
||||
|
||||
const addTagToNote = useCallback((noteId: string, tag: string) => {
|
||||
const clean = tag.trim();
|
||||
if (!clean) return;
|
||||
commitNotes(sortedNotesRef.current.map((n) => {
|
||||
if (n.id !== noteId) return n;
|
||||
const existing = n.tags ?? [];
|
||||
if (existing.includes(clean)) return n;
|
||||
return { ...n, tags: [...existing, clean], updatedAt: Date.now() };
|
||||
}));
|
||||
}, [commitNotes]);
|
||||
|
||||
const removeTagFromNote = useCallback((noteId: string, tagToRemove: string) => {
|
||||
commitNotes(sortedNotesRef.current.map((n) => {
|
||||
if (n.id !== noteId) return n;
|
||||
const existing = n.tags ?? [];
|
||||
const nextTags = existing.filter((t) => t !== tagToRemove);
|
||||
return { ...n, tags: nextTags.length ? nextTags : undefined, updatedAt: Date.now() };
|
||||
}));
|
||||
}, [commitNotes]);
|
||||
|
||||
const NOTE_DRAFT_DEBOUNCE_MS = 300;
|
||||
const draftNoteIdRef = useRef<string | null>(null);
|
||||
const draftTitleRef = useRef<string | null>(null);
|
||||
@@ -1124,6 +1161,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
|
||||
const renderNoteActions = (note: VaultNote, mode: "dropdown" | "context", closeMenu?: () => void) => {
|
||||
const actions = [
|
||||
{
|
||||
label: note.isPinned ? "取消置顶" : "置顶笔记",
|
||||
action: () => togglePinNoteById(note.id),
|
||||
},
|
||||
{
|
||||
label: t("common.rename"),
|
||||
action: () => setEditingNoteId(note.id),
|
||||
@@ -1742,7 +1783,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
>
|
||||
{selectedNoteView ? (
|
||||
<>
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-8 pt-6 pb-1" data-note-title-row>
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-2 px-8 pt-6 pb-1" data-note-title-row>
|
||||
<div className="min-w-0 flex-1">
|
||||
<NoteTitleInput
|
||||
noteId={selectedNoteView.id}
|
||||
@@ -1754,35 +1795,179 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
onBlur={() => flushNoteDraft()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pin Toggle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={selectedNoteView.isPinned ? "取消置顶" : "置顶笔记"}
|
||||
className={cn(
|
||||
"app-no-drag h-8 w-8 shrink-0 rounded-md p-0 transition-colors hover:bg-secondary/70",
|
||||
selectedNoteView.isPinned ? "text-amber-500" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => togglePinNoteById(selectedNoteView.id)}
|
||||
>
|
||||
<Pin size={16} className={selectedNoteView.isPinned ? "fill-amber-500" : ""} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{selectedNoteView.isPinned ? "取消置顶" : "置顶笔记"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Outline Toggle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="大纲目录"
|
||||
className={cn(
|
||||
"app-no-drag h-8 w-8 shrink-0 rounded-md p-0 transition-colors hover:bg-secondary/70",
|
||||
showOutline ? "bg-secondary text-primary font-medium" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setShowOutline((prev) => !prev)}
|
||||
>
|
||||
<ListTree size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">大纲目录</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Export Menu */}
|
||||
<NoteExportMenu note={selectedNoteView} allNotes={sortedNotes} />
|
||||
{renderNoteExportButton(selectedNoteView)}
|
||||
{renderNoteModeToggle()}
|
||||
|
||||
{/* Delete Note */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("action.delete")}
|
||||
className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => requestDeleteNoteById(selectedNoteView.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t("action.delete")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Tags & Folder Breadcrumb Header */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-8 pb-2 text-xs text-muted-foreground">
|
||||
{selectedNoteView.group && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-muted/70 rounded-md text-[11px] text-foreground font-medium">
|
||||
<Folder size={12} className="text-amber-500" />
|
||||
<span>{selectedNoteView.group}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{selectedNoteView.tags?.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 bg-primary/10 text-primary border border-primary/20 rounded-md text-[11px] font-medium"
|
||||
>
|
||||
<Hash size={11} className="opacity-70" />
|
||||
<span>{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 rounded-full hover:bg-primary/20 text-primary/70 hover:text-primary transition-colors"
|
||||
onClick={() => removeTagFromNote(selectedNoteView.id, tag)}
|
||||
title="移除标签"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{tagInputOpen ? (
|
||||
<div className="inline-flex items-center gap-1 bg-background border border-primary/60 rounded-md px-1.5 py-0.5">
|
||||
<Hash size={11} className="text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={newTagText}
|
||||
onChange={(e) => setNewTagText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (newTagText.trim()) {
|
||||
addTagToNote(selectedNoteView.id, newTagText.trim());
|
||||
setNewTagText("");
|
||||
setTagInputOpen(false);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setTagInputOpen(false);
|
||||
setNewTagText("");
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (newTagText.trim()) {
|
||||
addTagToNote(selectedNoteView.id, newTagText.trim());
|
||||
}
|
||||
setTagInputOpen(false);
|
||||
setNewTagText("");
|
||||
}}
|
||||
placeholder="标签名..."
|
||||
className="w-20 bg-transparent text-[11px] text-foreground outline-none placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md hover:bg-muted text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setTagInputOpen(true)}
|
||||
>
|
||||
<Plus size={11} />
|
||||
<span>添加标签</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 min-h-0 min-w-0">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div
|
||||
className="min-h-full w-full px-8 pt-2 pb-6"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
flushNoteDraft();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-editor">
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
noteId={selectedNoteView.id}
|
||||
value={selectedNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
onChange={(content) => saveNoteContentDraft(selectedNoteView.id, content)}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{showOutline && (
|
||||
<div className="w-60 shrink-0 h-full">
|
||||
<NoteOutline
|
||||
content={selectedNoteView.content}
|
||||
onClose={() => setShowOutline(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div
|
||||
className="min-h-full w-full px-8 pt-2 pb-6"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
flushNoteDraft();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-editor">
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
noteId={selectedNoteView.id}
|
||||
value={selectedNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
onChange={(content) => saveNoteContentDraft(selectedNoteView.id, content)}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center px-4">
|
||||
@@ -1833,7 +2018,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-4 pt-5 pb-1" data-note-title-row>
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-2 px-4 pt-5 pb-1" data-note-title-row>
|
||||
<div className="min-w-0 flex-1">
|
||||
<NoteTitleInput
|
||||
noteId={overlayNoteView.id}
|
||||
@@ -1845,6 +2030,30 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
onBlur={() => flushNoteDraft()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pin Toggle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={overlayNoteView.isPinned ? "取消置顶" : "置顶笔记"}
|
||||
className={cn(
|
||||
"app-no-drag h-8 w-8 shrink-0 rounded-md p-0 transition-colors hover:bg-secondary/70",
|
||||
overlayNoteView.isPinned ? "text-amber-500" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => togglePinNoteById(overlayNoteView.id)}
|
||||
>
|
||||
<Pin size={16} className={overlayNoteView.isPinned ? "fill-amber-500" : ""} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{overlayNoteView.isPinned ? "取消置顶" : "置顶笔记"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<NoteExportMenu note={overlayNoteView} allNotes={sortedNotes} />
|
||||
{renderNoteExportButton(overlayNoteView)}
|
||||
{renderNoteModeToggle()}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Edit2,
|
||||
FileQuestion,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Hash,
|
||||
Pin,
|
||||
Plus,
|
||||
Tag,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import {
|
||||
ancestorNoteGroupPaths,
|
||||
cleanNoteGroupPath,
|
||||
extractAllNoteTags,
|
||||
getNoteGroupLeafName,
|
||||
getNoteGroupParentPath,
|
||||
isNoteGroupInside,
|
||||
joinNoteGroupPath,
|
||||
type NoteFilterMode,
|
||||
type VaultNote,
|
||||
} from "../../domain/notes";
|
||||
|
||||
export interface NotesSidebarProps {
|
||||
notes: VaultNote[];
|
||||
noteGroups: string[];
|
||||
selectedGroup: string | null;
|
||||
selectedTag: string | null;
|
||||
filterMode: NoteFilterMode;
|
||||
onSelectFilterMode: (mode: NoteFilterMode) => void;
|
||||
onSelectGroup: (group: string | null) => void;
|
||||
onSelectTag: (tag: string | null) => void;
|
||||
onCreateGroup: (parentGroup?: string | null) => void;
|
||||
onRenameGroup: (group: string) => void;
|
||||
onDeleteGroup: (group: string) => void;
|
||||
onImportMarkdown?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface GroupTreeItem {
|
||||
path: string;
|
||||
name: string;
|
||||
depth: number;
|
||||
hasChildren: boolean;
|
||||
directCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export const NotesSidebar: React.FC<NotesSidebarProps> = ({
|
||||
notes,
|
||||
noteGroups,
|
||||
selectedGroup,
|
||||
selectedTag,
|
||||
filterMode,
|
||||
onSelectFilterMode,
|
||||
onSelectGroup,
|
||||
onSelectTag,
|
||||
onCreateGroup,
|
||||
onRenameGroup,
|
||||
onDeleteGroup,
|
||||
onImportMarkdown,
|
||||
className = "",
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleFolder = (path: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Compute counts for quick filter views
|
||||
const counts = useMemo(() => {
|
||||
const pinned = notes.filter((n) => n.isPinned).length;
|
||||
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
const recent = notes.filter((n) => n.updatedAt >= sevenDaysAgo).length;
|
||||
const uncategorized = notes.filter((n) => !n.group).length;
|
||||
return {
|
||||
all: notes.length,
|
||||
pinned,
|
||||
recent,
|
||||
uncategorized,
|
||||
};
|
||||
}, [notes]);
|
||||
|
||||
// Compute tags
|
||||
const allTags = useMemo(() => extractAllNoteTags(notes), [notes]);
|
||||
|
||||
// Build sorted folder hierarchy
|
||||
const folderTree = useMemo(() => {
|
||||
const allGroupPaths = new Set<string>();
|
||||
for (const g of noteGroups) {
|
||||
if (g.trim()) {
|
||||
const clean = cleanNoteGroupPath(g);
|
||||
ancestorNoteGroupPaths(clean).forEach((p) => allGroupPaths.add(p));
|
||||
}
|
||||
}
|
||||
for (const note of notes) {
|
||||
if (note.group) {
|
||||
ancestorNoteGroupPaths(cleanNoteGroupPath(note.group)).forEach((p) =>
|
||||
allGroupPaths.add(p),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedPaths = Array.from(allGroupPaths).sort((a, b) =>
|
||||
a.localeCompare(b, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
|
||||
const items: GroupTreeItem[] = [];
|
||||
for (const path of sortedPaths) {
|
||||
const parts = path.split("/");
|
||||
const depth = parts.length - 1;
|
||||
const name = getNoteGroupLeafName(path);
|
||||
const hasChildren = sortedPaths.some(
|
||||
(other) => other !== path && other.startsWith(`${path}/`),
|
||||
);
|
||||
const directCount = notes.filter((n) => n.group === path).length;
|
||||
const totalCount = notes.filter((n) => isNoteGroupInside(n.group, path)).length;
|
||||
|
||||
// Check if all parent folders are expanded
|
||||
let visible = true;
|
||||
let cur = getNoteGroupParentPath(path);
|
||||
while (cur) {
|
||||
if (!expandedFolders.has(cur)) {
|
||||
visible = false;
|
||||
break;
|
||||
}
|
||||
cur = getNoteGroupParentPath(cur);
|
||||
}
|
||||
|
||||
if (visible || depth === 0) {
|
||||
items.push({
|
||||
path,
|
||||
name,
|
||||
depth,
|
||||
hasChildren,
|
||||
directCount,
|
||||
totalCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [noteGroups, notes, expandedFolders]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col h-full bg-card/60 border-r border-border select-none text-xs ${className}`}
|
||||
>
|
||||
{/* Sidebar Header */}
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-border/80 shrink-0">
|
||||
<span className="font-semibold text-foreground tracking-wide text-[13px] flex items-center gap-2">
|
||||
<FileText size={16} className="text-primary" />
|
||||
<span>{t("notes.title") || "笔记管理"}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{onImportMarkdown && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={onImportMarkdown}
|
||||
title={t("notes.importMarkdown") || "导入 Markdown"}
|
||||
>
|
||||
<Upload size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => onCreateGroup(null)}
|
||||
title={t("notes.newGroup") || "新建文件夹"}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Navigation Scroll Area */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-4">
|
||||
{/* Quick Filter Section */}
|
||||
<div className="space-y-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-md transition-colors ${
|
||||
!selectedGroup && !selectedTag && filterMode === "all"
|
||||
? "bg-primary/15 text-primary font-medium"
|
||||
: "text-foreground hover:bg-muted/70"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectGroup(null);
|
||||
onSelectTag(null);
|
||||
onSelectFilterMode("all");
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<FileText size={14} className="opacity-70" />
|
||||
<span>{t("notes.allNotes") || "全部笔记"}</span>
|
||||
</span>
|
||||
<span className="text-[11px] opacity-60 bg-muted px-1.5 py-0.2 rounded-full">
|
||||
{counts.all}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-md transition-colors ${
|
||||
!selectedGroup && !selectedTag && filterMode === "pinned"
|
||||
? "bg-primary/15 text-primary font-medium"
|
||||
: "text-foreground hover:bg-muted/70"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectGroup(null);
|
||||
onSelectTag(null);
|
||||
onSelectFilterMode("pinned");
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Pin size={14} className="text-amber-500 opacity-90" />
|
||||
<span>{t("notes.pinned") || "已置顶"}</span>
|
||||
</span>
|
||||
<span className="text-[11px] opacity-60 bg-muted px-1.5 py-0.2 rounded-full">
|
||||
{counts.pinned}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-md transition-colors ${
|
||||
!selectedGroup && !selectedTag && filterMode === "recent"
|
||||
? "bg-primary/15 text-primary font-medium"
|
||||
: "text-foreground hover:bg-muted/70"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectGroup(null);
|
||||
onSelectTag(null);
|
||||
onSelectFilterMode("recent");
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Clock size={14} className="text-blue-500 opacity-80" />
|
||||
<span>{t("notes.recent") || "最近修改"}</span>
|
||||
</span>
|
||||
<span className="text-[11px] opacity-60 bg-muted px-1.5 py-0.2 rounded-full">
|
||||
{counts.recent}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-md transition-colors ${
|
||||
!selectedGroup && !selectedTag && filterMode === "uncategorized"
|
||||
? "bg-primary/15 text-primary font-medium"
|
||||
: "text-foreground hover:bg-muted/70"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectGroup(null);
|
||||
onSelectTag(null);
|
||||
onSelectFilterMode("uncategorized");
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<FileQuestion size={14} className="text-purple-500 opacity-80" />
|
||||
<span>{t("notes.uncategorized") || "未分类"}</span>
|
||||
</span>
|
||||
<span className="text-[11px] opacity-60 bg-muted px-1.5 py-0.2 rounded-full">
|
||||
{counts.uncategorized}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Folders / Notebooks Section */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between px-2 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
<span>{t("notes.folders") || "笔记本 / 文件夹"}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 rounded hover:bg-muted hover:text-foreground transition-colors"
|
||||
onClick={() => onCreateGroup(null)}
|
||||
title="新建文件夹"
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{folderTree.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-[11px] text-muted-foreground/60 italic">
|
||||
暂无文件夹,点击 + 创建
|
||||
</div>
|
||||
) : (
|
||||
folderTree.map((item) => {
|
||||
const isSelected = selectedGroup === item.path;
|
||||
const isExpanded = expandedFolders.has(item.path);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className={`group/folder flex items-center justify-between px-2 py-1 rounded-md transition-colors cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-primary/15 text-primary font-medium"
|
||||
: "text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
style={{ paddingLeft: `${Math.max(8, item.depth * 14 + 8)}px` }}
|
||||
onClick={() => {
|
||||
onSelectGroup(item.path);
|
||||
onSelectTag(null);
|
||||
onSelectFilterMode("all");
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
{item.hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 rounded hover:bg-muted text-muted-foreground shrink-0"
|
||||
onClick={(e) => toggleFolder(item.path, e)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={12} />
|
||||
) : (
|
||||
<ChevronRight size={12} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-3.5 shrink-0" />
|
||||
)}
|
||||
|
||||
{isExpanded ? (
|
||||
<FolderOpen size={14} className="text-amber-500 shrink-0" />
|
||||
) : (
|
||||
<Folder size={14} className="text-amber-500 shrink-0" />
|
||||
)}
|
||||
<span className="truncate text-xs">{item.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[11px] opacity-60 group-hover/folder:hidden">
|
||||
{item.totalCount}
|
||||
</span>
|
||||
<div className="hidden group-hover/folder:flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCreateGroup(item.path);
|
||||
}}
|
||||
title="新建子文件夹"
|
||||
>
|
||||
<FolderPlus size={11} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRenameGroup(item.path);
|
||||
}}
|
||||
title="重命名"
|
||||
>
|
||||
<Edit2 size={11} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-background text-destructive hover:text-destructive/80"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteGroup(item.path);
|
||||
}}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags Section */}
|
||||
{allTags.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between px-2 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Tag size={12} />
|
||||
<span>{t("notes.tags") || "标签"}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{allTags.map(({ tag, count }) => {
|
||||
const isSelected = selectedTag === tag;
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
className={`w-full flex items-center justify-between px-2.5 py-1 rounded-md transition-colors text-left ${
|
||||
isSelected
|
||||
? "bg-primary/15 text-primary font-medium"
|
||||
: "text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTag(isSelected ? null : tag);
|
||||
onSelectGroup(null);
|
||||
onSelectFilterMode("all");
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
<Hash size={12} className="opacity-50 shrink-0" />
|
||||
<span className="truncate">{tag}</span>
|
||||
</span>
|
||||
<span className="text-[11px] opacity-60 bg-muted px-1.5 py-0.2 rounded-full">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar Footer */}
|
||||
<div className="px-3 py-2 border-t border-border/80 text-[11px] text-muted-foreground flex items-center justify-between shrink-0">
|
||||
<span>共 {notes.length} 篇笔记</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -380,6 +380,7 @@ export interface VaultNote {
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
order?: number;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
buildVaultNoteMarkdownExportFiles,
|
||||
buildVaultNoteFromMarkdownImport,
|
||||
deriveNoteImportTitle,
|
||||
extractAllNoteTags,
|
||||
extractNoteHeadings,
|
||||
extractNoteSnippet,
|
||||
filterAndSortVaultNotes,
|
||||
getVaultNotesForExportScope,
|
||||
importMarkdownFilesToVaultNotes,
|
||||
importMarkdownPayloadsToVaultNotes,
|
||||
@@ -275,3 +279,70 @@ test("importMarkdownFilesToVaultNotes appends notes and skips unsupported files"
|
||||
assert.equal(result.notes[1].title, "Imported");
|
||||
assert.equal(result.notes[1].group, "Ops");
|
||||
});
|
||||
|
||||
test("extractNoteHeadings extracts markdown headings ignoring code blocks", () => {
|
||||
const content = `# Title 1
|
||||
Some intro text
|
||||
\`\`\`ts
|
||||
# Not a heading
|
||||
\`\`\`
|
||||
## Subtitle 1.1
|
||||
### Deep section
|
||||
`;
|
||||
const headings = extractNoteHeadings(content);
|
||||
assert.equal(headings.length, 3);
|
||||
assert.equal(headings[0].text, "Title 1");
|
||||
assert.equal(headings[0].level, 1);
|
||||
assert.equal(headings[1].text, "Subtitle 1.1");
|
||||
assert.equal(headings[1].level, 2);
|
||||
assert.equal(headings[2].text, "Deep section");
|
||||
assert.equal(headings[2].level, 3);
|
||||
});
|
||||
|
||||
test("extractNoteSnippet extracts clean plain text from markdown", () => {
|
||||
const content = `# Title
|
||||
Here is **bold** text and [a link](https://example.com) and \`code\`.
|
||||
- [x] Task 1
|
||||
> quote text
|
||||
`;
|
||||
const snippet = extractNoteSnippet(content, 50);
|
||||
assert.ok(!snippet.includes("#"));
|
||||
assert.ok(!snippet.includes("[a link]"));
|
||||
assert.ok(snippet.includes("bold"));
|
||||
assert.ok(snippet.includes("Task 1"));
|
||||
});
|
||||
|
||||
test("extractAllNoteTags aggregates and sorts tags by frequency", () => {
|
||||
const notes = [
|
||||
sanitizeVaultNote({ tags: ["dev", "prod"] }),
|
||||
sanitizeVaultNote({ tags: ["dev", "staging"] }),
|
||||
sanitizeVaultNote({ tags: ["dev"] }),
|
||||
];
|
||||
const tags = extractAllNoteTags(notes);
|
||||
assert.equal(tags[0].tag, "dev");
|
||||
assert.equal(tags[0].count, 3);
|
||||
});
|
||||
|
||||
test("filterAndSortVaultNotes filters by pinned, tags, group and sorts accordingly", () => {
|
||||
const notes = [
|
||||
sanitizeVaultNote({ id: "1", title: "B Note", isPinned: false, updatedAt: 100, tags: ["ssh"] }),
|
||||
sanitizeVaultNote({ id: "2", title: "A Note", isPinned: true, updatedAt: 50, tags: ["ssh"] }),
|
||||
sanitizeVaultNote({ id: "3", title: "C Note", isPinned: false, updatedAt: 200, group: "Ops" }),
|
||||
];
|
||||
|
||||
// Pinned first
|
||||
const sorted = filterAndSortVaultNotes(notes, { sort: "updatedDesc" });
|
||||
assert.equal(sorted[0].id, "2"); // Pinned note first
|
||||
assert.equal(sorted[1].id, "3"); // 200 updatedAt
|
||||
assert.equal(sorted[2].id, "1"); // 100 updatedAt
|
||||
|
||||
// Filter pinned only
|
||||
const pinnedOnly = filterAndSortVaultNotes(notes, { filterMode: "pinned" });
|
||||
assert.equal(pinnedOnly.length, 1);
|
||||
assert.equal(pinnedOnly[0].id, "2");
|
||||
|
||||
// Filter by tag
|
||||
const tagFiltered = filterAndSortVaultNotes(notes, { tag: "ssh" });
|
||||
assert.equal(tagFiltered.length, 2);
|
||||
});
|
||||
|
||||
|
||||
+150
@@ -38,6 +38,7 @@ export const sanitizeVaultNote = (note: Partial<VaultNote>): VaultNote => {
|
||||
createdAt,
|
||||
updatedAt,
|
||||
order: typeof note.order === "number" && Number.isFinite(note.order) ? note.order : undefined,
|
||||
isPinned: note.isPinned ? true : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -462,3 +463,152 @@ export const importMarkdownFilesToVaultNotes = async (
|
||||
skippedCount,
|
||||
};
|
||||
};
|
||||
|
||||
export interface NoteHeadingItem {
|
||||
id: string;
|
||||
level: number;
|
||||
text: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export const extractNoteHeadings = (content: string): NoteHeadingItem[] => {
|
||||
if (!content) return [];
|
||||
const lines = content.split("\n");
|
||||
const headings: NoteHeadingItem[] = [];
|
||||
let inCodeBlock = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (line.trim().startsWith("```")) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
continue;
|
||||
}
|
||||
if (inCodeBlock) continue;
|
||||
|
||||
const match = /^(#{1,6})\s+(.+)$/.exec(line.trim());
|
||||
if (match) {
|
||||
const level = match[1].length;
|
||||
const text = match[2].trim();
|
||||
headings.push({
|
||||
id: `heading-${i}-${text.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "-")}`,
|
||||
level,
|
||||
text,
|
||||
line: i + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return headings;
|
||||
};
|
||||
|
||||
export const extractNoteSnippet = (content: string, maxLength = 100): string => {
|
||||
if (!content) return "";
|
||||
let text = content.replace(/```[\s\S]*?```/g, " ");
|
||||
text = text.replace(/`([^`]+)`/g, "$1");
|
||||
text = text.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1");
|
||||
text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
|
||||
text = text.replace(/^#{1,6}\s+/gm, "");
|
||||
text = text.replace(/^>\s+/gm, "");
|
||||
text = text.replace(/^[-*+]\s+(\[[ xX]\]\s+)?/gm, "");
|
||||
text = text.replace(/^\d+\.\s+/gm, "");
|
||||
text = text.replace(/\s+/g, " ").trim();
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, maxLength).trim()}...`;
|
||||
};
|
||||
|
||||
export const extractAllNoteTags = (notes: VaultNote[]): { tag: string; count: number }[] => {
|
||||
const map = new Map<string, number>();
|
||||
for (const note of notes) {
|
||||
if (note.tags) {
|
||||
for (const t of note.tags) {
|
||||
const clean = t.trim();
|
||||
if (clean) {
|
||||
map.set(clean, (map.get(clean) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.map(([tag, count]) => ({ tag, count }))
|
||||
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
||||
};
|
||||
|
||||
export type NoteSortOption =
|
||||
| "updatedDesc"
|
||||
| "updatedAsc"
|
||||
| "createdDesc"
|
||||
| "createdAsc"
|
||||
| "titleAsc"
|
||||
| "titleDesc"
|
||||
| "custom";
|
||||
|
||||
export type NoteFilterMode = "all" | "pinned" | "recent" | "uncategorized";
|
||||
|
||||
export const filterAndSortVaultNotes = (
|
||||
notes: VaultNote[],
|
||||
options: {
|
||||
search?: string;
|
||||
group?: string | null;
|
||||
tag?: string | null;
|
||||
filterMode?: NoteFilterMode;
|
||||
sort?: NoteSortOption;
|
||||
hosts?: Host[];
|
||||
} = {},
|
||||
): VaultNote[] => {
|
||||
const {
|
||||
search = "",
|
||||
group = null,
|
||||
tag = null,
|
||||
filterMode = "all",
|
||||
sort = "updatedDesc",
|
||||
hosts = [],
|
||||
} = options;
|
||||
|
||||
const filtered = notes.filter((note) => {
|
||||
if (filterMode === "pinned" && !note.isPinned) return false;
|
||||
if (filterMode === "uncategorized" && note.group) return false;
|
||||
if (filterMode === "recent") {
|
||||
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
if (note.updatedAt < sevenDaysAgo) return false;
|
||||
}
|
||||
|
||||
if (group) {
|
||||
if (!note.group || !isNoteGroupInside(note.group, group)) return false;
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
if (!note.tags || !note.tags.includes(tag)) return false;
|
||||
}
|
||||
|
||||
if (search.trim() && !matchesVaultNoteSearch(note, search, hosts)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return [...filtered].sort((a, b) => {
|
||||
if (sort !== "custom") {
|
||||
if (a.isPinned !== b.isPinned) {
|
||||
return a.isPinned ? -1 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
switch (sort) {
|
||||
case "updatedDesc":
|
||||
return b.updatedAt - a.updatedAt;
|
||||
case "updatedAsc":
|
||||
return a.updatedAt - b.updatedAt;
|
||||
case "createdDesc":
|
||||
return b.createdAt - a.createdAt;
|
||||
case "createdAsc":
|
||||
return a.createdAt - b.createdAt;
|
||||
case "titleAsc":
|
||||
return (a.title || "").localeCompare(b.title || "");
|
||||
case "titleDesc":
|
||||
return (b.title || "").localeCompare(a.title || "");
|
||||
case "custom":
|
||||
default:
|
||||
return (a.order ?? 0) - (b.order ?? 0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user