/**
* # 笔记(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
/>
} onClick={createNote} />
{listLoading ? (
) : sidebarItems.length === 0 ? (
) : (
(
void openNote(item)}
actions={[
{
e?.stopPropagation();
void removeNote(item.id);
}}
onCancel={(e) => e?.stopPropagation()}
>
} onClick={(e) => e.stopPropagation()} />
,
]}
>
{item.pinned && }
{item.title}
}
description={
{dayjs(item.updatedAt).format("MM-DD HH:mm")}
{(item.tags ?? []).length > 0 ? ` · ${item.tags.join(" · ")}` : ""}
}
/>
)}
/>
)}
{/* ── 右栏:Vditor 所见即所得 Markdown 编辑器(容器常驻,Empty 覆盖层控制显隐) ── */}
选择或新建一篇笔记
所见即所得 Markdown(Typora 式)· AI 助手续写/总结/润色/翻译
}
/>
);
}