/** * # 笔记(v3):BlockNote 编辑器(主流 Notion 风格开源编辑器) * - 斜杠菜单(/)、选中浮动工具栏、拖拽手柄、任务列表 —— 主流交互开箱即用 * - Markdown 进出(blocksToMarkdownLossy / tryParseMarkdownToBlocks),存储为 markdown * - **非阻塞保存**:保存请求后台执行(队列 + 防抖),切换笔记零卡顿 * - AI 助手:续写 / 总结 / 润色 / 翻译 / 大纲 / 起标题(结果以 markdown 回填) */ import { useCallback, useEffect, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { PageContainer } from "@ant-design/pro-components"; import { App as AntApp, Button, Dropdown, Empty, Input, List, Popconfirm, Space, Spin, Tag, Tooltip, Typography, } from "antd"; import { DeleteOutlined, PlusOutlined, RobotOutlined, SearchOutlined, StarFilled, StarOutlined, } from "@ant-design/icons"; import Vditor from "vditor"; import "vditor/dist/index.css"; import dayjs from "dayjs"; import { noteApi } from "../services/api"; import type { NoteListItem } from "../services/api"; import { useThemeStore } from "../stores/theme"; /** # AI 动作定义:replaces = true 整篇替换 / false 追加到末尾 / "title" 设标题 **/ const AI_ACTIONS: { key: string; label: string; replaces: boolean | "title" }[] = [ { key: "continue", label: "✍️ 续写(追加到末尾)", replaces: false }, { key: "summarize", label: "📌 总结要点", replaces: true }, { key: "improve", label: "✨ 润色全文", replaces: true }, { key: "translate_en", label: "🇬🇧 翻译成英文", replaces: true }, { key: "translate_zh", label: "🇨🇳 翻译成中文", replaces: true }, { key: "outline", label: "🗂️ 生成大纲", replaces: true }, { key: "title", label: "🏷️ 起标题", replaces: "title" }, ]; /** # 遗留 HTML 内容 → 纯文本(旧数据兼容) **/ function legacyToMarkdown(content: string): string { const trimmed = (content ?? "").trim(); if (!trimmed) return ""; if (trimmed.startsWith("<")) { return trimmed .replace(/<\/(p|h[1-6]|li|div)>/gi, "\n\n") .replace(/]*>/gi, "- ") .replace(/<[^>]+>/g, "") .replace(/\n{3,}/g, "\n\n") .trim(); } return trimmed; } export default function NotesPage() { const { message } = AntApp.useApp(); const queryClient = useQueryClient(); const { mode } = useThemeStore(); const dark = mode === "dark"; const [activeId, setActiveId] = useState(null); const [search, setSearch] = useState(""); const [title, setTitle] = useState(""); const [tags, setTags] = useState([]); const [pinned, setPinned] = useState(false); const [saveState, setSaveState] = useState<"idle" | "pending" | "saving" | "saved">("idle"); const [aiRunning, setAiRunning] = useState(null); const [editorReady, setEditorReadyState] = useState(false); /** # 编辑器就绪前暂存待加载内容 **/ const pendingContentRef = useRef(null); const composingRef = useRef(false); /** # Vditor 编辑器实例 **/ const editorRef = useRef(null); const mountRef = useRef(null); /** # 非阻塞保存队列:防抖 + 后台执行 + 完成后再补发 **/ const pendingRef = useRef<{ id: string; dto: Parameters[1] } | null>(null); const inFlightRef = useRef(false); const timerRef = useRef | null>(null); const loadingRef = useRef(false); const flush = useCallback(() => { if (inFlightRef.current) return; const job = pendingRef.current; if (!job) return; pendingRef.current = null; inFlightRef.current = true; setSaveState("saving"); noteApi .update(job.id, job.dto) .then(() => setSaveState("saved")) .catch((e) => { setSaveState("idle"); message.error(`保存失败:${e instanceof Error ? e.message : e}`); }) .finally(() => { inFlightRef.current = false; if (pendingRef.current) flush(); }); }, [message]); const queueSave = useCallback( (immediate = false) => { if (!activeId) return; pendingRef.current = { id: activeId, dto: { title: title || "无标题", tags, pinned }, }; setSaveState("pending"); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(flush, immediate ? 0 : 900); }, [activeId, title, tags, pinned, flush], ); /** # 编辑器内容变更(Vditor input 回调;排除程序化加载) **/ const onVditorInput = useCallback(() => { if (loadingRef.current) return; setSaveState("pending"); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(flush, 900); }, [flush]); /** # 初始化 Vditor(主题切换时重建) **/ useEffect(() => { if (!mountRef.current || editorRef.current) return; const vditor = new Vditor(mountRef.current, { mode: "wysiwyg", theme: dark ? "dark" : "classic", height: "52vh", placeholder: "开始写点什么…(/ 唤起菜单;支持 Markdown 快捷输入)", cache: { enable: false }, value: "", after: () => { setEditorReadyState(true); if (pendingContentRef.current !== null) { vditor.setValue(pendingContentRef.current); pendingContentRef.current = null; } }, input: () => onVditorInput(), counter: { enable: true }, toolbar: [ "headings", "bold", "italic", "strike", "|", "list", "ordered-list", "check", "outdent", "indent", "|", "quote", "code", "inline-code", "link", "table", "|", "undo", "redo", "|", "edit-mode", "export", ], }); editorRef.current = vditor; return () => { vditor.destroy(); editorRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [dark]); /** # 笔记列表 **/ const { data: notes, isLoading: listLoading } = useQuery({ queryKey: ["notes", search], queryFn: () => noteApi.list(search ? { q: search } : {}), }); /** # 打开笔记:后台补发未存内容 + 立即加载(零等待) **/ const openNote = useCallback( async (item: NoteListItem) => { if (item.id === activeId) return; // 未保存内容后台补发,不阻塞切换 if (pendingRef.current || saveState === "pending") queueSave(true); loadingRef.current = true; setActiveId(item.id); setTitle(item.title); setTags((item.tags ?? []) as string[]); setPinned(item.pinned); setSaveState("idle"); try { const full = await noteApi.get(item.id); setTitle(full.title); setTags((full.tags ?? []) as string[]); setPinned(full.pinned); if (editorRef.current && editorReady) { editorRef.current.setValue(legacyToMarkdown(full.content)); } else { pendingContentRef.current = legacyToMarkdown(full.content); } } catch (e) { message.error(e instanceof Error ? e.message : "加载失败"); } finally { loadingRef.current = false; } }, // eslint-disable-next-line react-hooks/exhaustive-deps [activeId, editorReady, saveState, queueSave], ); /** # 新建笔记 **/ const createNote = async () => { try { const created = await noteApi.create({ title: "无标题", content: "", tags: [] }); queryClient.invalidateQueries({ queryKey: ["notes"] }); setActiveId(created.id); setTitle(created.title); setTags([]); setPinned(false); if (editorRef.current && editorReady) editorRef.current.setValue(""); else pendingContentRef.current = ""; message.success("已创建"); } catch (e) { message.error(e instanceof Error ? e.message : "创建失败"); } }; /** # 删除笔记 **/ const removeNote = async (id: string) => { pendingRef.current = null; await noteApi.delete(id); if (activeId === id) { setActiveId(null); if (editorRef.current && editorReady) editorRef.current.setValue(""); setTitle(""); } queryClient.invalidateQueries({ queryKey: ["notes"] }); message.success("已删除"); }; /** # 切换置顶 **/ const togglePin = async () => { if (!activeId) return; const next = !pinned; setPinned(next); pendingRef.current = { id: activeId, dto: { pinned: next } }; flush(); queryClient.invalidateQueries({ queryKey: ["notes"] }); }; /** # AI 动作 **/ const runAi = async (action: string, replaces: boolean | "title") => { if (!activeId) return; setAiRunning(action); try { const res = await noteApi.ai(activeId, { action }); if (replaces === "title") { const t = res.result.replace(/^["'#\s]+|["'\s]+$/g, "").slice(0, 60); setTitle(t); pendingRef.current = { id: activeId!, dto: { title: t } }; flush(); message.success("标题已生成"); return; } if (editorRef.current && editorReady) { if (replaces) editorRef.current.setValue(res.result); else editorRef.current.insertValue("\n\n" + res.result); } queueSave(true); message.success(replaces ? "已替换全文" : "已追加到末尾"); } catch (e) { message.error(e instanceof Error ? e.message : "AI 执行失败"); } finally { setAiRunning(null); } }; const sidebarItems = notes ?? []; return (
{/* ── 左栏 ── */}
} placeholder="搜索标题与正文" value={search} onChange={(e) => setSearch(e.target.value)} allowClear />
{/* ── 右栏:Vditor 所见即所得 Markdown 编辑器(容器常驻,Empty 覆盖层控制显隐) ── */}
选择或新建一篇笔记
所见即所得 Markdown(Typora 式)· AI 助手续写/总结/润色/翻译 } />
{ setTitle(e.target.value); queueSave(); }} style={{ fontSize: 22, fontWeight: 700, padding: 0 }} />
{ const v = (e.target as HTMLInputElement).value.trim(); if (v && !tags.includes(v)) { setTags([...tags, v]); queueSave(); } (e.target as HTMLInputElement).value = ""; }} /> {tags.map((t) => ( { setTags(tags.filter((x) => x !== t)); queueSave(); }} > {t} ))} ({ key: a.key, label: a.label })), onClick: ({ key }) => { const def = AI_ACTIONS.find((a) => a.key === key)!; void runAi(def.key, def.replaces); }, }} >
(composingRef.current = true)} onCompositionEnd={() => (composingRef.current = false)} />
所见即所得 Markdown · 输入「/」唤起菜单 · 工具栏可切换标题级别 · 自动保存(后台执行,切换不卡顿)
); }