0.1.13 AI功能大改
This commit is contained in:
@@ -15,27 +15,75 @@ type AgentAssetItem = {
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
|
||||
type MindmapInstanceLike = {
|
||||
setData?: (data: unknown) => void;
|
||||
command?: { clearHistory?: () => void };
|
||||
};
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
const getValue = (obj: unknown, key: string): unknown => (isRecord(obj) ? obj[key] : undefined);
|
||||
|
||||
type ToolName =
|
||||
| "mindmap_get"
|
||||
| "mindmap_get_subtree"
|
||||
| "search_web"
|
||||
| "rag_lightrag_query"
|
||||
| "image_read"
|
||||
| "mindmap_apply_ops"
|
||||
| "pdf_replace_mindmap";
|
||||
| "mindmap_add_child"
|
||||
| "mindmap_add_sibling_after"
|
||||
| "mindmap_update_node_text"
|
||||
| "mindmap_set_hyperlink"
|
||||
| "mindmap_append_note"
|
||||
| "mindmap_set_refs"
|
||||
| "mindmap_delete_node"
|
||||
| "mindmap_add_attachment_ref"
|
||||
| "mindmap_add_attachment_child"
|
||||
| "mindmap_add_image_child"
|
||||
| "mindmap_append_image_note"
|
||||
| "mindmap_expand_node";
|
||||
|
||||
const TOOL_LABEL: Record<ToolName, string> = {
|
||||
mindmap_get: "读导图(摘要)",
|
||||
mindmap_get_subtree: "读子树(按 uid)",
|
||||
search_web: "联网检索(SearxNG)",
|
||||
rag_lightrag_query: "LightRAG 检索",
|
||||
image_read: "图片读取(OCR)",
|
||||
mindmap_apply_ops: "写入导图(ops)",
|
||||
pdf_replace_mindmap: "PDF→导图(替换当前)",
|
||||
mindmap_add_child: "新增子节点",
|
||||
mindmap_add_sibling_after: "新增同级(在后面)",
|
||||
mindmap_update_node_text: "更新节点文本",
|
||||
mindmap_set_hyperlink: "设置/清除超链接",
|
||||
mindmap_append_note: "追加备注",
|
||||
mindmap_set_refs: "设置 refs(引用)",
|
||||
mindmap_delete_node: "删除节点",
|
||||
mindmap_add_attachment_ref: "增加附件引用(refs)",
|
||||
mindmap_add_attachment_child: "附件→子节点",
|
||||
mindmap_add_image_child: "图片→子节点",
|
||||
mindmap_append_image_note: "备注插入图片",
|
||||
mindmap_expand_node: "补完节点(检索→写入)",
|
||||
};
|
||||
|
||||
const DEFAULT_TOOLS: ToolName[] = [
|
||||
"mindmap_get",
|
||||
"mindmap_get_subtree",
|
||||
"search_web",
|
||||
"rag_lightrag_query",
|
||||
"image_read",
|
||||
"mindmap_expand_node",
|
||||
"mindmap_add_child",
|
||||
"mindmap_update_node_text",
|
||||
"mindmap_set_hyperlink",
|
||||
"mindmap_append_note",
|
||||
"mindmap_set_refs",
|
||||
"mindmap_delete_node",
|
||||
"mindmap_add_attachment_ref",
|
||||
"mindmap_add_attachment_child",
|
||||
"mindmap_add_image_child",
|
||||
"mindmap_append_image_note",
|
||||
"mindmap_apply_ops",
|
||||
"pdf_replace_mindmap",
|
||||
];
|
||||
|
||||
const ONLINE_MODELS = [
|
||||
@@ -54,8 +102,8 @@ export function MindmapAiAgentPanel({
|
||||
}: {
|
||||
documentId: string;
|
||||
mindmapId: string;
|
||||
mindmap: any;
|
||||
activeNodes: any[];
|
||||
mindmap: MindmapInstanceLike | null | undefined;
|
||||
activeNodes: unknown[];
|
||||
}) {
|
||||
const [messages, setMessages] = useState<AgentMessage[]>([
|
||||
{
|
||||
@@ -73,21 +121,35 @@ export function MindmapAiAgentPanel({
|
||||
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
|
||||
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
|
||||
const [aiModel, setAiModel] = useState<string>("");
|
||||
const [maxSteps, setMaxSteps] = useState<number>(10);
|
||||
|
||||
const [assets, setAssets] = useState<AgentAssetItem[]>([]);
|
||||
const [workspaceId, setWorkspaceId] = useState<string>("");
|
||||
const [attachments, setAttachments] = useState<AgentAssetItem[]>([]);
|
||||
const [debug, setDebug] = useState<string>("");
|
||||
const [toolLogs, setToolLogs] = useState<
|
||||
Array<
|
||||
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
|
||||
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
|
||||
| { type: "error"; message: string }
|
||||
>
|
||||
>([]);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim();
|
||||
const m = window.localStorage.getItem("mindmap_ai_model") || "";
|
||||
const stepsRaw = window.localStorage.getItem("mindmap_ai_max_steps") || "";
|
||||
if (p === "local" || p === "online") setAiProvider(p);
|
||||
if (typeof m === "string") setAiModel(m);
|
||||
const parsed = Number(stepsRaw);
|
||||
if (Number.isFinite(parsed) && parsed >= 1) {
|
||||
setMaxSteps(Math.max(1, Math.min(24, Math.floor(parsed))));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -97,10 +159,11 @@ export function MindmapAiAgentPanel({
|
||||
try {
|
||||
window.localStorage.setItem("mindmap_ai_provider", aiProvider);
|
||||
window.localStorage.setItem("mindmap_ai_model", aiModel);
|
||||
window.localStorage.setItem("mindmap_ai_max_steps", String(maxSteps));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [aiProvider, aiModel]);
|
||||
}, [aiProvider, aiModel, maxSteps]);
|
||||
|
||||
// @ 选择
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
@@ -123,25 +186,127 @@ export function MindmapAiAgentPanel({
|
||||
return false;
|
||||
};
|
||||
|
||||
const selectedUids = useMemo(() => {
|
||||
const parseSseChunks = async (res: Response, onEvent: (event: string, dataText: string) => void) => {
|
||||
if (!res.body) throw new Error("响应不支持流式读取");
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (true) {
|
||||
const sep = buffer.indexOf("\n\n");
|
||||
if (sep === -1) break;
|
||||
const raw = buffer.slice(0, sep);
|
||||
buffer = buffer.slice(sep + 2);
|
||||
|
||||
// 注释/心跳:以 ":" 开头
|
||||
if (raw.trimStart().startsWith(":")) continue;
|
||||
|
||||
const lines = raw.split(/\r?\n/);
|
||||
let event = "message";
|
||||
const dataLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event:")) event = line.slice("event:".length).trim() || "message";
|
||||
if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).trimStart());
|
||||
}
|
||||
onEvent(event, dataLines.join("\n"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selectedNodes = useMemo(() => {
|
||||
const list = Array.isArray(activeNodes) ? activeNodes : [];
|
||||
|
||||
const readUid = (n: unknown) => {
|
||||
const nodeData = getValue(n, "nodeData");
|
||||
const nodeDataData = getValue(nodeData, "data");
|
||||
const uidFromNodeData = String(getValue(nodeDataData, "uid") ?? getValue(nodeData, "uid") ?? "").trim();
|
||||
if (uidFromNodeData) return uidFromNodeData;
|
||||
|
||||
// 兼容 xmind-editor 节点对象:getData("uid")
|
||||
if (isRecord(n)) {
|
||||
const getData = n["getData"];
|
||||
if (typeof getData === "function") {
|
||||
try {
|
||||
const uid = (getData as (key: string) => unknown)("uid");
|
||||
const s = String(uid ?? "").trim();
|
||||
if (s) return s;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return String(getValue(n, "uid") ?? "").trim();
|
||||
};
|
||||
|
||||
const toPlainText = (input: unknown) => {
|
||||
const s = String(input ?? "").trim();
|
||||
if (!s) return "";
|
||||
// 选中节点的 text 可能是富文本 HTML(例如:<p>分支主题</p>),这里转换为纯文本展示给用户
|
||||
if (!/[<>]/.test(s)) return s;
|
||||
try {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = s;
|
||||
return String(div.textContent || div.innerText || "").trim();
|
||||
} catch {
|
||||
return s.replace(/<[^>]*>/g, "").trim();
|
||||
}
|
||||
};
|
||||
|
||||
const readText = (n: unknown) => {
|
||||
try {
|
||||
// 优先走 getData("text"),其次兜底 nodeData/data 结构
|
||||
if (isRecord(n)) {
|
||||
const getData = n["getData"];
|
||||
if (typeof getData === "function") {
|
||||
try {
|
||||
const raw = (getData as (key: string) => unknown)("text");
|
||||
const text = toPlainText(raw);
|
||||
if (text) return text;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodeData = getValue(n, "nodeData");
|
||||
const nodeDataData = getValue(nodeData, "data");
|
||||
const raw =
|
||||
getValue(nodeDataData, "text") ??
|
||||
getValue(nodeData, "text") ??
|
||||
getValue(getValue(n, "data"), "text") ??
|
||||
"";
|
||||
return toPlainText(raw);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
return list
|
||||
.slice(0, 3)
|
||||
.map((n) => String(n?.nodeData?.data?.uid ?? n?.nodeData?.uid ?? n?.getData?.("uid") ?? n?.uid ?? ""))
|
||||
.filter(Boolean);
|
||||
.map((n) => ({ uid: readUid(n), text: readText(n) }))
|
||||
.filter((x) => Boolean(x.uid));
|
||||
}, [activeNodes]);
|
||||
|
||||
const selectedUids = useMemo(() => selectedNodes.map((n) => n.uid), [selectedNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!documentId) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(documentId)}`);
|
||||
const json = (await res.json().catch(() => null)) as any;
|
||||
const json: unknown = await res.json().catch(() => null);
|
||||
if (!res.ok) return;
|
||||
if (cancelled) return;
|
||||
setWorkspaceId(String(json?.workspaceId ?? ""));
|
||||
setAssets(Array.isArray(json?.items) ? (json.items as AgentAssetItem[]) : []);
|
||||
const obj = isRecord(json) ? json : {};
|
||||
setWorkspaceId(String(obj.workspaceId ?? ""));
|
||||
setAssets(Array.isArray(obj.items) ? (obj.items as AgentAssetItem[]) : []);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -236,26 +401,28 @@ export function MindmapAiAgentPanel({
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/media/upload", { method: "POST", body: form });
|
||||
const json = (await res.json().catch(() => null)) as any;
|
||||
if (!res.ok) throw new Error(String(json?.error ?? `上传失败:${res.status}`));
|
||||
const asset = json?.asset;
|
||||
const json: unknown = await res.json().catch(() => null);
|
||||
const obj = isRecord(json) ? json : {};
|
||||
if (!res.ok) throw new Error(String(obj.error ?? `上传失败:${res.status}`));
|
||||
const asset = isRecord(obj.asset) ? obj.asset : {};
|
||||
const item: AgentAssetItem = {
|
||||
kind: "media",
|
||||
id: String(asset?.id ?? `media:${Date.now()}`),
|
||||
title: String(asset?.file_name ?? file.name),
|
||||
fileUrl: String(asset?.file_url ?? ""),
|
||||
mimeType: String(asset?.mime_type ?? file.type ?? ""),
|
||||
assetType: String(asset?.asset_type ?? "file"),
|
||||
fileName: String(asset?.file_name ?? file.name),
|
||||
id: String(asset.id ?? `media:${Date.now()}`),
|
||||
title: String(asset.file_name ?? file.name),
|
||||
fileUrl: String(asset.file_url ?? ""),
|
||||
mimeType: String(asset.mime_type ?? file.type ?? ""),
|
||||
assetType: String(asset.asset_type ?? "file"),
|
||||
fileName: String(asset.file_name ?? file.name),
|
||||
};
|
||||
setAttachments((prev) => (prev.some((x) => x.id === item.id) ? prev : [...prev, item]));
|
||||
// 刷新资产列表
|
||||
try {
|
||||
const listRes = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(documentId)}`);
|
||||
const listJson = (await listRes.json().catch(() => null)) as any;
|
||||
if (listRes.ok && Array.isArray(listJson?.items)) {
|
||||
setAssets(listJson.items as AgentAssetItem[]);
|
||||
setWorkspaceId(String(listJson?.workspaceId ?? workspaceId));
|
||||
const listJson: unknown = await listRes.json().catch(() => null);
|
||||
const listObj = isRecord(listJson) ? listJson : {};
|
||||
if (listRes.ok && Array.isArray(listObj.items)) {
|
||||
setAssets(listObj.items as AgentAssetItem[]);
|
||||
setWorkspaceId(String(listObj.workspaceId ?? workspaceId));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -272,6 +439,7 @@ export function MindmapAiAgentPanel({
|
||||
const content = input.trim();
|
||||
if (!content) return;
|
||||
setDebug("");
|
||||
setToolLogs([]);
|
||||
|
||||
const nextMessages: AgentMessage[] = [...messages, { role: "user", content }];
|
||||
setMessages(nextMessages);
|
||||
@@ -279,43 +447,157 @@ export function MindmapAiAgentPanel({
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/mindmap-ai/agent", {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
// 不把面板的“欢迎语”当作对话历史发送给服务端,避免影响任务执行
|
||||
const payloadMessages = nextMessages.filter(
|
||||
(m, idx) => !(idx === 0 && m.role === "assistant" && /思维导图 AI Agent/.test(m.content)),
|
||||
);
|
||||
|
||||
const res = await fetch("/api/ai-agent/run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
documentId,
|
||||
mindmapId,
|
||||
selectedUids,
|
||||
messages: nextMessages,
|
||||
stream: true,
|
||||
maxSteps,
|
||||
scope: "mindmap",
|
||||
messages: payloadMessages.slice(-24),
|
||||
toolChoice: toolAuto
|
||||
? {
|
||||
mode: "auto",
|
||||
toolSets: [
|
||||
"toolset.readonly",
|
||||
"toolset.rag_read",
|
||||
"toolset.media_read",
|
||||
"toolset.mindmap_read",
|
||||
"toolset.mindmap_write",
|
||||
],
|
||||
}
|
||||
: { mode: "manual", tools: selectedTools },
|
||||
context: { documentId, mindmapId, selectedUids },
|
||||
attachments: attachments.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
fileUrl: a.fileUrl,
|
||||
mimeType: a.mimeType ?? null,
|
||||
})),
|
||||
toolChoice: toolAuto ? { mode: "auto" } : { mode: "manual", tools: selectedTools },
|
||||
options: { searxng: networkOn, ai: { provider: aiProvider, model: aiModel.trim() || undefined } },
|
||||
}),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as any;
|
||||
if (!res.ok) throw new Error(String(json?.error ?? `请求失败:${res.status}`));
|
||||
|
||||
const assistantText = String(json?.message ?? "");
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: assistantText || "(无输出)" }]);
|
||||
|
||||
if (json?.data) {
|
||||
mindmap?.setData?.(json.data);
|
||||
mindmap?.command?.clearHistory?.();
|
||||
persistMindmapData(json.data);
|
||||
if (!res.ok) {
|
||||
const j = (await res.json().catch(() => null)) as unknown;
|
||||
const err = typeof j === "object" && j && "error" in j ? String((j as Record<string, unknown>).error ?? "") : "";
|
||||
throw new Error(err || `请求失败:${res.status}`);
|
||||
}
|
||||
|
||||
if (json?.trace) {
|
||||
setDebug(JSON.stringify(json.trace, null, 2));
|
||||
}
|
||||
const rawEvents: Array<{ event: string; dataText: string }> = [];
|
||||
await parseSseChunks(res, (event, dataText) => {
|
||||
rawEvents.push({ event, dataText });
|
||||
|
||||
if (event === "tool_call") {
|
||||
try {
|
||||
const data = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = (typeof data === "object" && data ? (data as Record<string, unknown>) : {}) as Record<string, unknown>;
|
||||
setToolLogs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "tool_call",
|
||||
id: String(obj.id ?? ""),
|
||||
tool: String(obj.tool ?? ""),
|
||||
args: (typeof obj.args === "object" && obj.args ? (obj.args as Record<string, unknown>) : {}) as Record<string, unknown>,
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "tool_result") {
|
||||
try {
|
||||
const data = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = (typeof data === "object" && data ? (data as Record<string, unknown>) : {}) as Record<string, unknown>;
|
||||
const tool = String(obj.tool ?? "");
|
||||
const result = "result" in obj ? obj.result : null;
|
||||
setToolLogs((prev) => [
|
||||
...prev,
|
||||
{ type: "tool_result", id: String(obj.id ?? ""), tool, ok: Boolean(obj.ok), ms: Number(obj.ms ?? 0), result },
|
||||
]);
|
||||
|
||||
// 若工具返回了新的 mindmap data,则立即应用到前端实例并落盘(与 MindmapSidebar 的策略一致)
|
||||
if (
|
||||
(tool === "mindmap_apply_ops" ||
|
||||
tool === "mindmap_expand_node" ||
|
||||
tool === "mindmap_add_child" ||
|
||||
tool === "mindmap_add_sibling_after" ||
|
||||
tool === "mindmap_update_node_text" ||
|
||||
tool === "mindmap_set_hyperlink" ||
|
||||
tool === "mindmap_append_note" ||
|
||||
tool === "mindmap_set_refs" ||
|
||||
tool === "mindmap_delete_node" ||
|
||||
tool === "mindmap_add_attachment_ref" ||
|
||||
tool === "mindmap_add_attachment_child" ||
|
||||
tool === "mindmap_add_image_child" ||
|
||||
tool === "mindmap_append_image_note") &&
|
||||
obj.ok
|
||||
) {
|
||||
const r = result as unknown;
|
||||
const dataNode =
|
||||
typeof r === "object" && r && "data" in (r as Record<string, unknown>) ? (r as Record<string, unknown>).data : null;
|
||||
if (dataNode) {
|
||||
try {
|
||||
mindmap?.setData?.(dataNode);
|
||||
mindmap?.command?.clearHistory?.();
|
||||
persistMindmapData(dataNode);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "assistant_message") {
|
||||
try {
|
||||
const data = JSON.parse(dataText || "null") as unknown;
|
||||
const assistantText =
|
||||
typeof data === "object" && data && "text" in data ? String((data as Record<string, unknown>).text ?? "") : "";
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: assistantText.trim() ? assistantText.trim() : "(无输出)" },
|
||||
]);
|
||||
} catch {
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: "(无输出)" }]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "error") {
|
||||
try {
|
||||
const data = JSON.parse(dataText || "null") as unknown;
|
||||
const msg =
|
||||
typeof data === "object" && data && "message" in data ? String((data as Record<string, unknown>).message ?? "") : "";
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: msg || "未知错误" }]);
|
||||
} catch {
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: "未知错误" }]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
setDebug(JSON.stringify(rawEvents.slice(-120), null, 2));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: `执行失败:${msg}` }]);
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: msg }]);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -323,8 +605,20 @@ export function MindmapAiAgentPanel({
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between gap-2 pb-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
{selectedUids.length ? `选中节点:${selectedUids[0]}` : "未选中节点(将以整图为上下文)"}
|
||||
<div
|
||||
className="text-xs text-gray-500"
|
||||
title={
|
||||
selectedNodes.length
|
||||
? selectedNodes
|
||||
.map((n) => (n.text ? `${n.text}(${n.uid})` : n.uid))
|
||||
.slice(0, 3)
|
||||
.join(",")
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{selectedNodes.length
|
||||
? `选中节点:${selectedNodes[0]?.text || selectedNodes[0]?.uid}${selectedNodes.length > 1 ? `(+${selectedNodes.length - 1})` : ""}`
|
||||
: "未选中节点(将以整图为上下文)"}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
@@ -463,6 +757,24 @@ export function MindmapAiAgentPanel({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-gray-500">最大步数:</div>
|
||||
<input
|
||||
className="w-[72px] rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
step={1}
|
||||
value={maxSteps}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (!Number.isFinite(v)) return;
|
||||
setMaxSteps(Math.max(1, Math.min(24, Math.floor(v))));
|
||||
}}
|
||||
title="工具调用/推理最大步数(上限 24)"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{aiProvider === "local" ? (
|
||||
<div className="mt-1 text-[11px] text-gray-400">
|
||||
@@ -510,7 +822,8 @@ export function MindmapAiAgentPanel({
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
// Enter 发送;Shift+Enter 换行
|
||||
if (!e.shiftKey && !e.isComposing) {
|
||||
const isComposing = Boolean((e.nativeEvent as unknown as { isComposing?: boolean })?.isComposing);
|
||||
if (!e.shiftKey && !isComposing) {
|
||||
e.preventDefault();
|
||||
if (!loading) void send();
|
||||
}
|
||||
@@ -547,21 +860,67 @@ export function MindmapAiAgentPanel({
|
||||
<div className="text-[11px] text-gray-400">Enter 发送 · Shift+Enter 换行</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
disabled={loading || !input.trim()}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{loading ? "执行中..." : "发送"}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
disabled={!loading}
|
||||
onClick={() => abortRef.current?.abort()}
|
||||
title="停止本次执行"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
停止
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
disabled={loading || !input.trim()}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{loading ? "执行中..." : "发送"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="mt-2 rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-600">
|
||||
<summary className="cursor-pointer select-none">工具日志(可折叠)</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{toolLogs.length === 0 ? <div className="text-gray-400">暂无工具日志</div> : null}
|
||||
{toolLogs.map((l, idx) => {
|
||||
if (l.type === "error") {
|
||||
return (
|
||||
<div key={idx} className="rounded border border-red-200 bg-red-50 p-2 text-red-700">
|
||||
错误:{l.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "tool_call") {
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
<div className="text-[11px] text-gray-400">tool_call · {l.id}</div>
|
||||
<div className="font-medium text-gray-800">{l.tool}</div>
|
||||
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap">{JSON.stringify(l.args, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
<div className="text-[11px] text-gray-400">
|
||||
tool_result · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
|
||||
</div>
|
||||
<div className="font-medium text-gray-800">{l.tool}</div>
|
||||
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap">{JSON.stringify(l.result, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{debug ? (
|
||||
<details className="mt-2 rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-600">
|
||||
<summary className="cursor-pointer select-none">调试信息(tool trace)</summary>
|
||||
<summary className="cursor-pointer select-none">调试信息(SSE 事件)</summary>
|
||||
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap">{debug}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { MindmapSidebarTrigger } from "./MindmapSidebarTrigger";
|
||||
import { MindmapNavigator } from "./MindmapNavigator";
|
||||
import { MindmapMiniMap } from "./MindmapMiniMap";
|
||||
import { MindmapCount } from "./MindmapCount";
|
||||
import { MindmapContextMenu } from "./MindmapContextMenu";
|
||||
import type { SidebarPanel } from "./mindmapSidebarConfig";
|
||||
import type { MindMapNode } from "./mindmapTypes";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -51,6 +52,10 @@ const getImageSizeSafe = (url: string): Promise<{ width: number; height: number
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
// 类型检查辅助函数
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
type MindMapInstance = {
|
||||
execCommand: (command: string, ...args: unknown[]) => void;
|
||||
destroy: () => void;
|
||||
@@ -132,7 +137,7 @@ const normalizeMindmapData = (input: unknown): unknown => {
|
||||
return input;
|
||||
};
|
||||
|
||||
// 持久化/初始化统一使用“根节点对象”作为数据载体,避免把包含额外字段的 wrapper 误传给 simple-mind-map
|
||||
// 持久化/初始化统一使用"根节点对象"作为数据载体,避免把包含额外字段的 wrapper 误传给 simple-mind-map
|
||||
// 从而触发 RichText 对 wrapper.data 的处理(wrapper.data.text 可能不存在 → htmlEscape 崩溃)。
|
||||
const canonicalizeMindmapData = (input: unknown): MindMapData => {
|
||||
const normalized = (normalizeMindmapData(input) ?? defaultMindmapData) as any;
|
||||
@@ -142,6 +147,82 @@ const canonicalizeMindmapData = (input: unknown): MindMapData => {
|
||||
return (normalizeMindmapData(root) ?? defaultMindmapData) as MindMapData;
|
||||
};
|
||||
|
||||
// 将思维导图数据中的 asset:id 格式转换为实际的签名 URL
|
||||
// 返回转换后的数据和 signed URL -> asset ID 的映射
|
||||
const resolveAssetUrls = async (data: MindMapData): Promise<{ data: MindMapData; urlToAssetId: Map<string, string> }> => {
|
||||
const assetIds: string[] = [];
|
||||
|
||||
// 递归收集所有 asset:id
|
||||
const collectAssetIds = (node: any) => {
|
||||
if (!node) return;
|
||||
if (node.image?.url?.startsWith?.("asset:")) {
|
||||
assetIds.push(node.image.url.replace("asset:", ""));
|
||||
}
|
||||
if (Array.isArray(node.children)) {
|
||||
node.children.forEach(collectAssetIds);
|
||||
}
|
||||
};
|
||||
collectAssetIds(data);
|
||||
|
||||
// 如果没有 asset:id,直接返回原数据和空映射
|
||||
if (assetIds.length === 0) return { data, urlToAssetId: new Map() };
|
||||
|
||||
// 批量获取签名 URL
|
||||
const urlMap = new Map<string, string>();
|
||||
await Promise.all(assetIds.map(async (id) => {
|
||||
try {
|
||||
const response = await fetch(`/api/media/sign?assetId=${id}`);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
urlMap.set(id, result.signedUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`获取 asset ${id} 签名 URL 失败`, e);
|
||||
}
|
||||
}));
|
||||
|
||||
// 递归替换 asset:id 为签名 URL,并建立反向映射
|
||||
const urlToAssetId = new Map<string, string>();
|
||||
const replaceAssetIds = (node: any): any => {
|
||||
if (!node) return node;
|
||||
const newNode = { ...node };
|
||||
if (newNode.image?.url?.startsWith?.("asset:")) {
|
||||
const id = newNode.image.url.replace("asset:", "");
|
||||
const signedUrl = urlMap.get(id);
|
||||
if (signedUrl) {
|
||||
newNode.image = { ...newNode.image, url: signedUrl };
|
||||
// 建立反向映射:signedUrl -> asset:id
|
||||
urlToAssetId.set(signedUrl, id);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(newNode.children)) {
|
||||
newNode.children = newNode.children.map(replaceAssetIds);
|
||||
}
|
||||
return newNode;
|
||||
};
|
||||
|
||||
return { data: replaceAssetIds(data), urlToAssetId };
|
||||
};
|
||||
|
||||
// 将思维导图数据中的签名 URL 转换回 asset:id 格式(用于保存)
|
||||
const revertToAssetIds = (data: MindMapData, urlToAssetId: Map<string, string>): MindMapData => {
|
||||
const revertNode = (node: any): any => {
|
||||
if (!node) return node;
|
||||
const newNode = { ...node };
|
||||
if (newNode.image?.url && typeof newNode.image.url === "string") {
|
||||
const assetId = urlToAssetId.get(newNode.image.url);
|
||||
if (assetId) {
|
||||
newNode.image = { ...newNode.image, url: `asset:${assetId}` };
|
||||
}
|
||||
}
|
||||
if (Array.isArray(newNode.children)) {
|
||||
newNode.children = newNode.children.map(revertNode);
|
||||
}
|
||||
return newNode;
|
||||
};
|
||||
return revertNode(data);
|
||||
};
|
||||
|
||||
const STORAGE_PREFIX = "wolai-mindmap-autosave-";
|
||||
|
||||
function downloadJson(data: unknown, name: string) {
|
||||
@@ -329,7 +410,7 @@ const MindmapBlockView = ({
|
||||
const hotkeyScopeRef = useRef(false);
|
||||
const lastInteractionAtRef = useRef(0);
|
||||
const skipNextPasteRef = useRef(false);
|
||||
const persistDataRef = useRef<((data: unknown) => void) | null>(null);
|
||||
const persistDataRef = useRef<((data: unknown) => void) | null>(null);
|
||||
const recentNodeDblclickRef = useRef(false);
|
||||
const deletingRef = useRef(false);
|
||||
const textEditOpenRef = useRef(false);
|
||||
@@ -337,6 +418,12 @@ const MindmapBlockView = ({
|
||||
const lastTextEditRefocusAtRef = useRef(0);
|
||||
const pendingInitActivateRootRef = useRef(false);
|
||||
const pendingRenderEndActivateRootRef = useRef(false);
|
||||
// 存储 signed URL 到 asset:id 的映射,用于保存时转换回 asset:id 格式
|
||||
const signedUrlToAssetIdRef = useRef(new Map<string, string>());
|
||||
// 存储当前思维导图中使用的所有图片 URL,用于检测图片删除
|
||||
const currentImageUrlsRef = useRef(new Set<string>());
|
||||
// 存储已删除的 asset ID,用于撤销时恢复
|
||||
const deletedAssetIdsRef = useRef(new Set<string>());
|
||||
|
||||
const getRootNode = useCallback((mm?: MindMapInstance | null) => {
|
||||
const instance = mm ?? mindmap;
|
||||
@@ -353,6 +440,27 @@ const MindmapBlockView = ({
|
||||
);
|
||||
const mindmapId = block.id;
|
||||
|
||||
// 获取 workspaceId(用于上传图片)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!docId) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(docId)}`);
|
||||
const json: unknown = await res.json().catch(() => null);
|
||||
if (!res.ok) return;
|
||||
if (cancelled) return;
|
||||
const obj = isRecord(json) ? json : {};
|
||||
setWorkspaceId(String(obj.workspaceId ?? ""));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [docId]);
|
||||
|
||||
useEffect(() => {
|
||||
hasLocalEditsRef.current = false;
|
||||
applyingRemoteRef.current = false;
|
||||
@@ -1089,13 +1197,17 @@ const MindmapBlockView = ({
|
||||
(data: unknown) => {
|
||||
if (!editor) return;
|
||||
// 关键:全屏/非全屏切换会重建 mindmap 实例,初始化数据必须跟随最新编辑
|
||||
// 否则切换视图时会用旧数据,表现为“看起来没保存”
|
||||
const safe = canonicalizeMindmapData(data);
|
||||
// 否则切换视图时会用旧数据,表现为"看起来没保存"
|
||||
let safe = canonicalizeMindmapData(data);
|
||||
// 将 signed URL 转换回 asset:id 格式用于保存
|
||||
if (signedUrlToAssetIdRef.current.size > 0) {
|
||||
safe = revertToAssetIds(safe, signedUrlToAssetIdRef.current);
|
||||
}
|
||||
initialDataRef.current = safe;
|
||||
window.localStorage.setItem(autosaveKey, JSON.stringify(safe));
|
||||
window.localStorage.setItem(autosaveKey, JSON.stringify(safe));
|
||||
// 注意:全屏(Portal 覆盖层)下如果调用 updateBlock,BlockNote/ProseMirror
|
||||
// 可能会在防抖保存时(例如 ~800ms)抢回焦点,导致节点双击编辑的输入框
|
||||
// 被迫退出(表现为“有光标但过一会儿就消失”)。
|
||||
// 被迫退出(表现为"有光标但过一会儿就消失")。
|
||||
// 因此全屏态只做 localStorage + 后端同步,等退出全屏(实例重建/卸载)
|
||||
// 时再统一把最新数据写回 block。
|
||||
if (!effectiveFullscreen) {
|
||||
@@ -1418,8 +1530,12 @@ const MindmapBlockView = ({
|
||||
dataForInitSource = block.props.data ? "block.props.data" : "initialDataRef";
|
||||
return block.props.data ?? initialDataRef.current ?? defaultMindmapData;
|
||||
})();
|
||||
const dataForInit = canonicalizeMindmapData(rawForInit);
|
||||
initialDataRef.current = dataForInit;
|
||||
const dataForInitCanonical = canonicalizeMindmapData(rawForInit);
|
||||
initialDataRef.current = dataForInitCanonical;
|
||||
// 将 asset:id 转换为签名 URL,并获取映射
|
||||
const { data: dataForInit, urlToAssetId } = await resolveAssetUrls(dataForInitCanonical);
|
||||
// 保存映射供后续保存时使用
|
||||
signedUrlToAssetIdRef.current = urlToAssetId;
|
||||
|
||||
type MindMapConstructor = new (options: {
|
||||
el: HTMLElement;
|
||||
@@ -1439,13 +1555,15 @@ const MindmapBlockView = ({
|
||||
const MindMapCtor = MindMap as unknown as MindMapConstructor;
|
||||
|
||||
// Portal 全屏时,simple-mind-map 的节点文本编辑框会 append 到 body,并受 z-index 影响:
|
||||
// 若 z-index 低于我们的全屏覆盖层,会出现“进入编辑态但看不到独立输入框/光标”的问题。
|
||||
// 若 z-index 低于我们的全屏覆盖层,会出现"进入编辑态但看不到独立输入框/光标"的问题。
|
||||
// 这里显式提高节点编辑框 z-index,并把内部浮层挂到 wrapper 上,确保全屏下可见且不会被遮挡。
|
||||
const fullscreenTextEditOptions = effectiveFullscreen
|
||||
? {
|
||||
// 让编辑框仍 append 到 body(库默认行为),只提高 z-index,避免被全屏覆盖层挡住。
|
||||
// 注意:如果把编辑框 append 到 wrapper(overflow-hidden),可能会影响 box-shadow/可见性。
|
||||
nodeTextEditZIndex: 100000,
|
||||
// 将图片调整遮罩挂载到容器内,确保全屏下可见且事件正常
|
||||
customInnerElsAppendTo: containerRef.current || undefined,
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -1950,12 +2068,97 @@ const MindmapBlockView = ({
|
||||
const [imageHeight, setImageHeight] = useState<number | string>(200);
|
||||
const [imageTitle, setImageTitle] = useState("");
|
||||
const [imagePosition, setImagePosition] = useState<string>("top");
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [workspaceId, setWorkspaceId] = useState("");
|
||||
const fileInputForImage = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// 图片预览(双击节点图片)
|
||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||
const [viewerSrc, setViewerSrc] = useState("");
|
||||
const [viewerMeta, setViewerMeta] = useState<{ title?: string; width?: number; height?: number }>({});
|
||||
const [viewerZoom, setViewerZoom] = useState(1);
|
||||
|
||||
// 解析图片 URL,如果是 asset_id 格式则获取签名 URL
|
||||
const resolveImageUrl = async (urlOrAssetId: string): Promise<string> => {
|
||||
// 检查是否是 asset_id 格式
|
||||
if (urlOrAssetId.startsWith("asset:")) {
|
||||
const assetId = urlOrAssetId.replace("asset:", "");
|
||||
try {
|
||||
const response = await fetch(`/api/media/sign?assetId=${assetId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.signedUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("获取签名 URL 失败", e);
|
||||
}
|
||||
return urlOrAssetId;
|
||||
}
|
||||
// 如果是完整的 URL,直接返回
|
||||
return urlOrAssetId;
|
||||
};
|
||||
|
||||
// 递归收集思维导图中所有的图片 URL
|
||||
const collectImageUrls = (data: any): string[] => {
|
||||
if (!data) return [];
|
||||
const urls: string[] = [];
|
||||
const traverse = (node: any) => {
|
||||
if (!node) return;
|
||||
if (node.image && typeof node.image === "string" && node.image) {
|
||||
urls.push(node.image);
|
||||
}
|
||||
if (Array.isArray(node.children)) {
|
||||
node.children.forEach(traverse);
|
||||
}
|
||||
};
|
||||
traverse(data);
|
||||
return urls;
|
||||
};
|
||||
|
||||
// 处理图片删除(将 asset 移到垃圾桶)
|
||||
const deleteImageAssets = async (assetIds: string[]) => {
|
||||
if (!assetIds.length || !docId) return;
|
||||
try {
|
||||
console.log("[MindmapBlock] Deleting image assets:", assetIds);
|
||||
const response = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "delete", assetIds }),
|
||||
});
|
||||
if (response.ok) {
|
||||
// 记录到已删除列表
|
||||
assetIds.forEach(id => deletedAssetIdsRef.current.add(id));
|
||||
console.log("[MindmapBlock] Image assets deleted successfully, emitting ASSETS_CHANGED_EVENT");
|
||||
// 通知文件树刷新,传递被删除的 assetIds
|
||||
emitAssetsChanged(docId, undefined, assetIds);
|
||||
} else {
|
||||
const payload = await response.json().catch(() => null);
|
||||
console.error("[MindmapBlock] Failed to delete image assets:", payload?.error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("删除图片资源失败", e);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理图片恢复(从垃圾桶恢复)
|
||||
const restoreImageAssets = async (assetIds: string[]) => {
|
||||
if (!assetIds.length || !docId) return;
|
||||
try {
|
||||
const response = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "restore", assetIds }),
|
||||
});
|
||||
if (response.ok) {
|
||||
// 从已删除列表移除
|
||||
assetIds.forEach(id => deletedAssetIdsRef.current.delete(id));
|
||||
// 通知文件树刷新,传递恢复的 assetIds(空列表表示需要重新获取)
|
||||
emitAssetsChanged(docId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("恢复图片资源失败", e);
|
||||
}
|
||||
};
|
||||
const imgToolbarRef = useRef<HTMLDivElement | null>(null);
|
||||
const [imgToolbarState, setImgToolbarState] = useState({
|
||||
show: false,
|
||||
@@ -1964,20 +2167,35 @@ const MindmapBlockView = ({
|
||||
placement: "top" as "top" | "bottom" | "left" | "right",
|
||||
});
|
||||
const imgToolbarHover = useRef(false);
|
||||
const imgNodeRef = useRef<{ node: MindMapNode | null; imgNode: any }>({ node: null, imgNode: null });
|
||||
|
||||
const handleImage = () => {
|
||||
const first = mindmap?.renderer?.activeNodeList?.[0] as MindMapNode | undefined;
|
||||
if (first?.getStyle) {
|
||||
const placement = first.getStyle("imgPlacement", false) as string;
|
||||
const placement = first.getStyle("imgPlacement", false) as string;
|
||||
if (placement) setImagePosition(placement);
|
||||
}
|
||||
setShowImageModal(true);
|
||||
};
|
||||
|
||||
// 图片工具栏更新位置函数
|
||||
const updateImgToolbarPos = useCallback(() => {
|
||||
if (!imgToolbarState.show || !imgNodeRef.current.imgNode || !imgToolbarRef.current) return;
|
||||
const toolbarRect = imgToolbarRef.current.getBoundingClientRect();
|
||||
const imgRbox = imgNodeRef.current.imgNode?.rbox?.();
|
||||
if (!imgRbox) return;
|
||||
const { width: imgWidth, x, y } = imgRbox;
|
||||
setImgToolbarState((s) => ({
|
||||
...s,
|
||||
x: x + imgWidth / 2 - toolbarRect.width / 2,
|
||||
y: y - toolbarRect.height - 5,
|
||||
}));
|
||||
}, [imgToolbarState.show]);
|
||||
|
||||
// 预览:监听 mindmap 事件
|
||||
useEffect(() => {
|
||||
if (!mindmap) return;
|
||||
const handler = (node: MindMapNode, e?: Event) => {
|
||||
const handler = async (node: MindMapNode, e?: Event) => {
|
||||
e?.stopPropagation?.();
|
||||
e?.preventDefault?.();
|
||||
const srcRaw =
|
||||
@@ -1986,7 +2204,9 @@ const MindmapBlockView = ({
|
||||
node?.data?.image;
|
||||
const src = typeof srcRaw === "string" ? srcRaw : "";
|
||||
if (src) {
|
||||
setViewerSrc(src);
|
||||
// 解析图片 URL,如果是 asset_id 格式则获取签名 URL
|
||||
const resolvedUrl = await resolveImageUrl(src);
|
||||
setViewerSrc(resolvedUrl);
|
||||
const sizeRaw = node?.getData?.("imageSize");
|
||||
const size =
|
||||
sizeRaw && typeof sizeRaw === "object"
|
||||
@@ -1998,56 +2218,183 @@ const MindmapBlockView = ({
|
||||
width: Number(size.width) || undefined,
|
||||
height: Number(size.height) || undefined,
|
||||
});
|
||||
setViewerZoom(1); // 重置缩放
|
||||
setShowImageViewer(true);
|
||||
}
|
||||
};
|
||||
const onActive = () => {
|
||||
const onActive = (node: MindMapNode) => {
|
||||
if (node === imgNodeRef.current.node) return;
|
||||
const list = (mindmap.renderer?.activeNodeList || []) as MindMapNode[];
|
||||
const has = list.some((n) => !!n?.getData?.("image"));
|
||||
if (!has) setImgToolbarState((s) => ({ ...s, show: false }));
|
||||
if (!has) {
|
||||
setImgToolbarState((s) => ({ ...s, show: false }));
|
||||
imgNodeRef.current = { node: null, imgNode: null };
|
||||
}
|
||||
};
|
||||
const showToolbarOnClick = (node: MindMapNode, _svgImg: unknown, evt: Event | undefined) => {
|
||||
const target = evt?.target as Element | undefined;
|
||||
const bbox = target?.getBoundingClientRect?.();
|
||||
const showToolbarOnClick = (node: MindMapNode, imgNode: any, _evt: Event | undefined) => {
|
||||
imgNodeRef.current = { node, imgNode };
|
||||
const placement = (node?.getStyle?.("imgPlacement") || "top") as "top" | "bottom" | "left" | "right";
|
||||
if (!bbox) return;
|
||||
setImagePosition(placement);
|
||||
setImgToolbarState({
|
||||
setImgToolbarState((s) => ({
|
||||
...s,
|
||||
show: true,
|
||||
x: bbox.left,
|
||||
y: bbox.top,
|
||||
placement,
|
||||
}));
|
||||
// 使用 requestAnimationFrame 确保 DOM 更新后再定位
|
||||
requestAnimationFrame(() => {
|
||||
updateImgToolbarPos();
|
||||
});
|
||||
};
|
||||
const hideToolbar = () => {
|
||||
if (!imgToolbarHover.current) {
|
||||
setImgToolbarState((s) => ({ ...s, show: false }));
|
||||
imgNodeRef.current = { node: null, imgNode: null };
|
||||
}
|
||||
};
|
||||
mindmap.on?.("node_img_dblclick", handler);
|
||||
mindmap.on?.("node_active", onActive);
|
||||
mindmap.on?.("node_img_click", showToolbarOnClick);
|
||||
mindmap.on?.("draw_click", hideToolbar);
|
||||
// 添加更多事件监听以正确隐藏/更新工具栏
|
||||
mindmap.on?.("svg_mousedown", hideToolbar);
|
||||
mindmap.on?.("node_dblclick", hideToolbar);
|
||||
mindmap.on?.("scale", updateImgToolbarPos);
|
||||
mindmap.on?.("translate", hideToolbar);
|
||||
mindmap.on?.("node_img_adjust_btn_mousedown", hideToolbar);
|
||||
mindmap.on?.("delete_node_img_from_delete_btn", hideToolbar);
|
||||
return () => {
|
||||
mindmap.off?.("node_img_dblclick", handler);
|
||||
mindmap.off?.("node_active", onActive);
|
||||
mindmap.off?.("node_img_click", showToolbarOnClick);
|
||||
mindmap.off?.("draw_click", hideToolbar);
|
||||
mindmap.off?.("svg_mousedown", hideToolbar);
|
||||
mindmap.off?.("node_dblclick", hideToolbar);
|
||||
mindmap.off?.("scale", updateImgToolbarPos);
|
||||
mindmap.off?.("translate", hideToolbar);
|
||||
mindmap.off?.("node_img_adjust_btn_mousedown", hideToolbar);
|
||||
mindmap.off?.("delete_node_img_from_delete_btn", hideToolbar);
|
||||
};
|
||||
}, [mindmap]);
|
||||
}, [mindmap, updateImgToolbarPos]);
|
||||
|
||||
// 悬浮图片位置工具条
|
||||
// 处理图片删除/恢复(支持撤销)
|
||||
useEffect(() => {
|
||||
if (!mindmap || !docId) return;
|
||||
|
||||
console.log("[MindmapBlock] Setting up image deletion monitoring");
|
||||
|
||||
// 初始化:收集当前所有图片 URL
|
||||
const initialData = mindmap.getData?.();
|
||||
if (initialData) {
|
||||
const urls = collectImageUrls(initialData);
|
||||
currentImageUrlsRef.current = new Set(urls);
|
||||
console.log("[MindmapBlock] Initial image URLs:", urls.length, "Map size:", signedUrlToAssetIdRef.current.size);
|
||||
console.log("[MindmapBlock] Initial URLs:", urls);
|
||||
}
|
||||
|
||||
// 处理数据变化
|
||||
const handleDataChange = () => {
|
||||
console.log("[MindmapBlock] data_change event fired!");
|
||||
const newData = mindmap.getData?.();
|
||||
if (!newData) return;
|
||||
|
||||
const newUrls = collectImageUrls(newData);
|
||||
const newUrlsSet = new Set(newUrls);
|
||||
const oldUrlsSet = currentImageUrlsRef.current;
|
||||
|
||||
console.log("[MindmapBlock] Old URLs:", Array.from(oldUrlsSet));
|
||||
console.log("[MindmapBlock] New URLs:", newUrls);
|
||||
console.log("[MindmapBlock] Map entries:", Array.from(signedUrlToAssetIdRef.current.entries()));
|
||||
|
||||
// 检测被删除的图片(在旧集合中但不在新集合中)
|
||||
const deletedUrls: string[] = [];
|
||||
oldUrlsSet.forEach(url => {
|
||||
if (!newUrlsSet.has(url)) {
|
||||
deletedUrls.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// 检测新增的图片(在新集合中但不在旧集合中)
|
||||
const addedUrls: string[] = [];
|
||||
newUrlsSet.forEach(url => {
|
||||
if (!oldUrlsSet.has(url)) {
|
||||
addedUrls.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// 处理删除的图片
|
||||
if (deletedUrls.length > 0) {
|
||||
console.log("[MindmapBlock] Detected deleted URLs:", deletedUrls);
|
||||
const assetIdsToDelete: string[] = [];
|
||||
|
||||
deletedUrls.forEach(url => {
|
||||
const assetId = signedUrlToAssetIdRef.current.get(url);
|
||||
console.log("[MindmapBlock] URL:", url.substring(0, 100), "-> Asset ID:", assetId);
|
||||
if (assetId) {
|
||||
assetIdsToDelete.push(assetId);
|
||||
} else {
|
||||
console.warn("[MindmapBlock] Asset ID not found in map for URL:", url.substring(0, 100));
|
||||
}
|
||||
});
|
||||
|
||||
console.log("[MindmapBlock] Asset IDs to delete:", assetIdsToDelete);
|
||||
if (assetIdsToDelete.length > 0) {
|
||||
deleteImageAssets(assetIdsToDelete);
|
||||
} else {
|
||||
console.warn("[MindmapBlock] No asset IDs found for deleted URLs!");
|
||||
}
|
||||
}
|
||||
|
||||
// 处理恢复的图片(可能是撤销操作)
|
||||
if (addedUrls.length > 0) {
|
||||
console.log("[MindmapBlock] Detected added URLs:", addedUrls);
|
||||
const assetIdsToRestore: string[] = [];
|
||||
addedUrls.forEach(url => {
|
||||
const assetId = signedUrlToAssetIdRef.current.get(url);
|
||||
if (assetId && deletedAssetIdsRef.current.has(assetId)) {
|
||||
assetIdsToRestore.push(assetId);
|
||||
}
|
||||
});
|
||||
console.log("[MindmapBlock] Asset IDs to restore:", assetIdsToRestore);
|
||||
if (assetIdsToRestore.length > 0) {
|
||||
restoreImageAssets(assetIdsToRestore);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新当前图片集合
|
||||
currentImageUrlsRef.current = newUrlsSet;
|
||||
};
|
||||
|
||||
// 监听多种事件
|
||||
mindmap.on?.("data_change", handleDataChange);
|
||||
mindmap.on?.("back_forward", handleDataChange);
|
||||
mindmap.on?.("node_data_change", handleDataChange);
|
||||
|
||||
console.log("[MindmapBlock] Event listeners registered");
|
||||
|
||||
return () => {
|
||||
console.log("[MindmapBlock] Cleaning up event listeners");
|
||||
mindmap.off?.("data_change", handleDataChange);
|
||||
mindmap.off?.("back_forward", handleDataChange);
|
||||
mindmap.off?.("node_data_change", handleDataChange);
|
||||
};
|
||||
}, [mindmap, docId]);
|
||||
|
||||
// 悬浮图片位置工具条(参考 NodeImgPlacementToolbar.vue)
|
||||
const renderImgToolbar = () => {
|
||||
if (!imgToolbarState.show) return null;
|
||||
// 预览打开时隐藏工具栏
|
||||
if (!imgToolbarState.show || showImageViewer) return null;
|
||||
const { x, y, placement } = imgToolbarState;
|
||||
|
||||
const setPlacement = (p: "top" | "bottom" | "left" | "right") => {
|
||||
setImagePosition(p);
|
||||
applyToActiveNodes(mindmap, (node) =>
|
||||
mindmap?.execCommand?.("SET_NODE_STYLES", node, { imgPlacement: p }),
|
||||
applyToActiveNodes(mindmap, (n) =>
|
||||
mindmap?.execCommand?.("SET_NODE_STYLES", n, { imgPlacement: p }),
|
||||
);
|
||||
setImgToolbarState((s) => ({ ...s, placement: p }));
|
||||
};
|
||||
const btn = (
|
||||
|
||||
// 位置按钮组件
|
||||
const PosBtn = (
|
||||
p: typeof placement,
|
||||
Icon: React.ComponentType<{ className?: string }>,
|
||||
title: string,
|
||||
@@ -2055,12 +2402,11 @@ const MindmapBlockView = ({
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
className={`flex h-8 w-8 items-center justify-center rounded border bg-white/90 text-gray-700 shadow ${
|
||||
placement === p ? "border-blue-500 text-blue-600" : "border-gray-200"
|
||||
className={`flex h-8 w-8 items-center justify-center rounded border bg-white text-gray-700 shadow-sm ${
|
||||
placement === p ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200 hover:bg-gray-100"
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setPlacement(p);
|
||||
}}
|
||||
title={title}
|
||||
@@ -2068,24 +2414,18 @@ const MindmapBlockView = ({
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={imgToolbarRef}
|
||||
className="pointer-events-auto fixed z-[9999] flex gap-1 rounded-lg bg-black/50 p-2 backdrop-blur"
|
||||
style={{ left: x, top: y - 42 }}
|
||||
onMouseEnter={() => {
|
||||
imgToolbarHover.current = true;
|
||||
setImgToolbarState((s) => ({ ...s, show: true }));
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
imgToolbarHover.current = false;
|
||||
setImgToolbarState((s) => ({ ...s, show: false }));
|
||||
}}
|
||||
className="pointer-events-auto fixed z-[9999] flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 shadow-md"
|
||||
style={{ left: x, top: y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{btn("top", ArrowUp, "顶部")}
|
||||
{btn("bottom", ArrowDown, "底部")}
|
||||
{btn("left", ArrowLeft, "靠左")}
|
||||
{btn("right", ArrowRight, "靠右")}
|
||||
{PosBtn("top", ArrowUp, "顶部")}
|
||||
{PosBtn("bottom", ArrowDown, "底部")}
|
||||
{PosBtn("left", ArrowLeft, "靠左")}
|
||||
{PosBtn("right", ArrowRight, "靠右")}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2122,22 +2462,41 @@ const MindmapBlockView = ({
|
||||
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_NOTE", node, note));
|
||||
};
|
||||
|
||||
const handleImageConfirm = () => {
|
||||
const handleImageConfirm = async () => {
|
||||
const url = imageUrl.trim();
|
||||
const width = Number(imageWidth) || 260;
|
||||
const height = Number(imageHeight) || 200;
|
||||
// 获取原始图片尺寸,如果没有则使用默认值
|
||||
let width = Number(imageWidth) || 0;
|
||||
let height = Number(imageHeight) || 0;
|
||||
|
||||
if (!url) {
|
||||
window.alert("请输入图片链接");
|
||||
return;
|
||||
}
|
||||
setShowImageModal(false);
|
||||
|
||||
// 保存旧的 signed URL(如果存在),用于后续删除检测
|
||||
const oldData = mindmap?.getData?.();
|
||||
const oldUrls = oldData ? collectImageUrls(oldData) : [];
|
||||
|
||||
// 如果是 asset:id 格式,需要先转换为 signed URL 供显示
|
||||
let displayUrl = url;
|
||||
if (url.startsWith("asset:")) {
|
||||
displayUrl = await resolveImageUrl(url);
|
||||
// 记录映射关系供保存时使用
|
||||
if (displayUrl !== url) {
|
||||
const assetId = url.replace("asset:", "");
|
||||
signedUrlToAssetIdRef.current.set(displayUrl, assetId);
|
||||
console.log("[MindmapBlock] New image mapped:", displayUrl.substring(0, 80), "-> Asset ID:", assetId);
|
||||
}
|
||||
}
|
||||
|
||||
applyToActiveNodes(mindmap, (node) =>
|
||||
mindmap?.execCommand("SET_NODE_IMAGE", node, {
|
||||
url,
|
||||
url: displayUrl,
|
||||
width,
|
||||
height,
|
||||
title: imageTitle || "",
|
||||
position: imagePosition || "top",
|
||||
custom: false, // 让 simple-mind-map 自动缩放到合适尺寸
|
||||
}),
|
||||
);
|
||||
applyToActiveNodes(mindmap, (node) =>
|
||||
@@ -2145,6 +2504,16 @@ const MindmapBlockView = ({
|
||||
imgPlacement: imagePosition || "top",
|
||||
}),
|
||||
);
|
||||
|
||||
// 等待命令执行后更新 currentImageUrlsRef
|
||||
setTimeout(() => {
|
||||
const newData = mindmap?.getData?.();
|
||||
if (newData) {
|
||||
const newUrls = collectImageUrls(newData);
|
||||
currentImageUrlsRef.current = new Set(newUrls);
|
||||
console.log("[MindmapBlock] Updated currentImageUrlsRef after insert:", newUrls);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleAttachment = () => {
|
||||
@@ -2376,7 +2745,16 @@ const MindmapBlockView = ({
|
||||
|
||||
const imageViewer = showImageViewer ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={() => setShowImageViewer(false)}>
|
||||
<div className="relative max-h-full max-w-5xl text-white">
|
||||
<div
|
||||
className="relative max-h-full max-w-5xl text-white"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
setViewerZoom((prev) => Math.max(0.1, Math.min(5, prev + delta)));
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="absolute -top-3 -right-3 rounded-full bg-white/90 px-2 py-1 text-sm text-gray-700 shadow"
|
||||
onClick={(e) => {
|
||||
@@ -2387,21 +2765,46 @@ const MindmapBlockView = ({
|
||||
关闭
|
||||
</button>
|
||||
<div className="mb-2 flex items-center justify-between gap-4 px-1 text-xs text-gray-200">
|
||||
<span className="truncate">{viewerMeta.title || "图片预览"}</span>
|
||||
<span className="shrink-0">{viewerMeta.width && viewerMeta.height ? `${viewerMeta.width} × ${viewerMeta.height}px` : ""}</span>
|
||||
<span className="truncate">{viewerMeta.title || "图片预览"}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="shrink-0">{viewerMeta.width && viewerMeta.height ? `${viewerMeta.width} × ${viewerMeta.height}px` : ""}</span>
|
||||
<span className="shrink-0 text-blue-300">{Math.round(viewerZoom * 100)}%</span>
|
||||
<button
|
||||
className="shrink-0 rounded bg-white/20 px-2 py-0.5 hover:bg-white/30"
|
||||
onClick={() => setViewerZoom((prev) => Math.max(0.1, prev - 0.2))}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<button
|
||||
className="shrink-0 rounded bg-white/20 px-2 py-0.5 hover:bg-white/30"
|
||||
onClick={() => setViewerZoom(1)}
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
className="shrink-0 rounded bg-white/20 px-2 py-0.5 hover:bg-white/30"
|
||||
onClick={() => setViewerZoom((prev) => Math.min(5, prev + 0.2))}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={viewerSrc}
|
||||
alt={viewerMeta.title || "预览"}
|
||||
className="max-h-[80vh] max-w-[80vw] rounded-lg shadow-2xl object-contain bg-white"
|
||||
className="max-h-[80vh] max-w-[80vw] rounded-lg shadow-2xl object-contain bg-white transition-transform duration-100"
|
||||
style={{ transform: `scale(${viewerZoom})` }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const imgToolbar = renderImgToolbar();
|
||||
// 使用 createPortal 将工具栏挂载到 body,确保 position: fixed 相对于视口
|
||||
const imgToolbar = typeof document !== "undefined" && imgToolbarState.show
|
||||
? createPortal(renderImgToolbar(), document.body)
|
||||
: null;
|
||||
|
||||
const imageModal = showImageModal ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
@@ -2423,7 +2826,15 @@ const MindmapBlockView = ({
|
||||
选择文件
|
||||
</button>
|
||||
<span className="text-xs text-gray-400 truncate">
|
||||
{imageUrl.startsWith("data:") ? "已选择本地图片" : "未选择文件"}
|
||||
{uploadingImage
|
||||
? "上传中..."
|
||||
: imageUrl.startsWith("asset:")
|
||||
? "已选择图片"
|
||||
: imageUrl.startsWith("data:")
|
||||
? "已选择本地图片"
|
||||
: imageUrl
|
||||
? "已输入图片地址"
|
||||
: "未选择文件"}
|
||||
</span>
|
||||
<input
|
||||
ref={fileInputForImage}
|
||||
@@ -2433,17 +2844,42 @@ const MindmapBlockView = ({
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const dataUrl = String(reader.result);
|
||||
setImageUrl(dataUrl);
|
||||
const size = await getImageSizeSafe(dataUrl);
|
||||
if (size) {
|
||||
setImageWidth(size.width);
|
||||
setImageHeight(size.height);
|
||||
if (!workspaceId || !docId) {
|
||||
window.alert("缺少空间信息,无法上传文件");
|
||||
return;
|
||||
}
|
||||
setUploadingImage(true);
|
||||
try {
|
||||
// 先获取原始图片尺寸
|
||||
const localSize = await getImageSizeSafe(URL.createObjectURL(file));
|
||||
if (localSize) {
|
||||
setImageWidth(localSize.width);
|
||||
setImageHeight(localSize.height);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("workspaceId", workspaceId);
|
||||
form.append("documentId", docId);
|
||||
const response = await fetch("/api/media/upload", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(payload?.error ?? "上传失败");
|
||||
}
|
||||
const payload = (await response.json()) as { mindmapUrl?: string };
|
||||
if (!payload.mindmapUrl) {
|
||||
throw new Error("返回数据缺少图片地址");
|
||||
}
|
||||
setImageUrl(payload.mindmapUrl);
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
if (fileInputForImage.current) fileInputForImage.current.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -2619,6 +3055,7 @@ const MindmapBlockView = ({
|
||||
/>
|
||||
<MindmapCount mindmap={mindmap} />
|
||||
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
|
||||
<MindmapContextMenu mindmap={mindmap} />
|
||||
{imgToolbar}
|
||||
{noteModal}
|
||||
{imageModal}
|
||||
@@ -2710,6 +3147,7 @@ const MindmapBlockView = ({
|
||||
/>
|
||||
<MindmapCount mindmap={mindmap} />
|
||||
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
|
||||
<MindmapContextMenu mindmap={mindmap} />
|
||||
{imgToolbar}
|
||||
{noteModal}
|
||||
{imageModal}
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { MindMapNode } from "./mindmapTypes";
|
||||
|
||||
// 菜单项配置
|
||||
interface ContextMenuItem {
|
||||
key?: string;
|
||||
label?: string;
|
||||
shortcut?: string;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
show?: (node: MindMapNode | null) => boolean;
|
||||
}
|
||||
|
||||
// 节点右键菜单配置
|
||||
const NODE_MENU_ITEMS: ContextMenuItem[] = [
|
||||
{
|
||||
key: "INSERT_NODE",
|
||||
label: "插入同级节点",
|
||||
shortcut: "Enter",
|
||||
},
|
||||
{
|
||||
key: "INSERT_CHILD_NODE",
|
||||
label: "插入子级节点",
|
||||
shortcut: "Tab",
|
||||
},
|
||||
{
|
||||
key: "INSERT_PARENT_NODE",
|
||||
label: "插入父节点",
|
||||
shortcut: "Shift + Tab",
|
||||
},
|
||||
{
|
||||
key: "ADD_GENERALIZATION",
|
||||
label: "插入概要",
|
||||
shortcut: "Ctrl + G",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "UP_NODE",
|
||||
label: "上移节点",
|
||||
shortcut: "Ctrl + ↑",
|
||||
},
|
||||
{
|
||||
key: "DOWN_NODE",
|
||||
label: "下移节点",
|
||||
shortcut: "Ctrl + ↓",
|
||||
},
|
||||
{
|
||||
key: "UNEXPAND_ALL",
|
||||
label: "收起所有下级节点",
|
||||
},
|
||||
{
|
||||
key: "EXPAND_ALL",
|
||||
label: "展开所有下级节点",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "REMOVE_NODE",
|
||||
label: "删除节点",
|
||||
shortcut: "Delete",
|
||||
danger: true,
|
||||
},
|
||||
{
|
||||
key: "REMOVE_CURRENT_NODE",
|
||||
label: "仅删除当前节点",
|
||||
shortcut: "Shift + Backspace",
|
||||
danger: true,
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "COPY_NODE",
|
||||
label: "复制节点",
|
||||
shortcut: "Ctrl + C",
|
||||
},
|
||||
{
|
||||
key: "CUT_NODE",
|
||||
label: "剪切节点",
|
||||
shortcut: "Ctrl + X",
|
||||
},
|
||||
{
|
||||
key: "PASTE_NODE",
|
||||
label: "粘贴节点",
|
||||
shortcut: "Ctrl + V",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "REMOVE_HYPERLINK",
|
||||
label: "移除超链接",
|
||||
show: (node) => !!node?.getData?.("hyperlink"),
|
||||
},
|
||||
{
|
||||
key: "REMOVE_NOTE",
|
||||
label: "移除备注",
|
||||
show: (node) => !!node?.getData?.("note"),
|
||||
},
|
||||
{
|
||||
key: "REMOVE_CUSTOM_STYLES",
|
||||
label: "一键去除自定义样式",
|
||||
},
|
||||
{
|
||||
key: "EXPORT_CUR_NODE_TO_PNG",
|
||||
label: "导出该节点为图片",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "AI_CONTINUE",
|
||||
label: "AI续写",
|
||||
},
|
||||
];
|
||||
|
||||
interface MindmapContextMenuProps {
|
||||
mindmap: any | null;
|
||||
}
|
||||
|
||||
export function MindmapContextMenu({ mindmap }: MindmapContextMenuProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [targetNode, setTargetNode] = useState<MindMapNode | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const requestShowRef = useRef<{ x: number; y: number; node: MindMapNode } | null>(null);
|
||||
|
||||
// 判断是否禁用某个菜单项
|
||||
const isItemDisabled = useCallback(
|
||||
(item: ContextMenuItem): boolean => {
|
||||
if (!targetNode) return false;
|
||||
|
||||
const isRoot = (targetNode as any).isRoot === true;
|
||||
const isGeneralization = (targetNode as any).isGeneralization === true;
|
||||
|
||||
switch (item.key) {
|
||||
case "INSERT_NODE":
|
||||
case "INSERT_PARENT_NODE":
|
||||
case "ADD_GENERALIZATION":
|
||||
return isRoot || isGeneralization;
|
||||
|
||||
case "INSERT_CHILD_NODE":
|
||||
return isGeneralization;
|
||||
|
||||
case "COPY_NODE":
|
||||
case "CUT_NODE":
|
||||
return isGeneralization;
|
||||
|
||||
case "UP_NODE": {
|
||||
if (isRoot || isGeneralization) return true;
|
||||
const parent = (targetNode as any).parent;
|
||||
if (!parent || !Array.isArray(parent.children)) return true;
|
||||
return parent.children.findIndex((item: unknown) => item === targetNode) === 0;
|
||||
}
|
||||
|
||||
case "DOWN_NODE": {
|
||||
if (isRoot || isGeneralization) return true;
|
||||
const parent = (targetNode as any).parent;
|
||||
if (!parent || !Array.isArray(parent.children)) return true;
|
||||
const children = parent.children;
|
||||
return children.findIndex((item: unknown) => item === targetNode) === children.length - 1;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[targetNode]
|
||||
);
|
||||
|
||||
// 过滤显示的菜单项
|
||||
const getVisibleItems = useCallback((): ContextMenuItem[] => {
|
||||
return NODE_MENU_ITEMS.filter((item) => {
|
||||
if (item.divider) return true;
|
||||
if (item.show && !item.show(targetNode)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [targetNode]);
|
||||
|
||||
// 执行命令
|
||||
const executeCommand = useCallback(
|
||||
(key: string) => {
|
||||
if (!mindmap || !targetNode) return;
|
||||
|
||||
switch (key) {
|
||||
case "COPY_NODE":
|
||||
mindmap.renderer?.copy?.();
|
||||
break;
|
||||
case "CUT_NODE":
|
||||
mindmap.renderer?.cut?.();
|
||||
break;
|
||||
case "PASTE_NODE":
|
||||
mindmap.renderer?.paste?.();
|
||||
break;
|
||||
case "REMOVE_HYPERLINK":
|
||||
if (typeof (targetNode as any).setHyperlink === "function") {
|
||||
(targetNode as any).setHyperlink("", "");
|
||||
}
|
||||
break;
|
||||
case "REMOVE_NOTE":
|
||||
if (typeof (targetNode as any).setNote === "function") {
|
||||
(targetNode as any).setNote("");
|
||||
}
|
||||
break;
|
||||
case "EXPORT_CUR_NODE_TO_PNG": {
|
||||
const getTextFromHtml = (html: string) => {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = html;
|
||||
return div.textContent || div.innerText || "";
|
||||
};
|
||||
const nodeText = targetNode.getData?.("text") || "";
|
||||
mindmap.doExport?.export("png", true, getTextFromHtml(String(nodeText)), false, targetNode);
|
||||
break;
|
||||
}
|
||||
case "UNEXPAND_ALL":
|
||||
mindmap.execCommand?.(key, false, targetNode);
|
||||
break;
|
||||
case "EXPAND_ALL":
|
||||
mindmap.execCommand?.(key, (targetNode as any).uid || "");
|
||||
break;
|
||||
case "AI_CONTINUE":
|
||||
// 触发 AI 续写
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("mindmap-ai-continue", {
|
||||
detail: { node: targetNode },
|
||||
})
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
mindmap.execCommand?.(key);
|
||||
break;
|
||||
}
|
||||
|
||||
hide();
|
||||
},
|
||||
[mindmap, targetNode]
|
||||
);
|
||||
|
||||
// 隐藏菜单
|
||||
const hide = useCallback(() => {
|
||||
setVisible(false);
|
||||
setTargetNode(null);
|
||||
setPosition({ x: -9999, y: -9999 });
|
||||
requestShowRef.current = null;
|
||||
}, []);
|
||||
|
||||
// 显示菜单 - 使用 requestAnimationFrame 确保 DOM 更新后再显示
|
||||
const show = useCallback((x: number, y: number, node: MindMapNode) => {
|
||||
setTargetNode(node);
|
||||
|
||||
// 计算可见菜单项数量
|
||||
const visibleItems = NODE_MENU_ITEMS.filter((item) => {
|
||||
if (item.divider) return true;
|
||||
if (item.show && !item.show(node)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 计算实际菜单高度(每个菜单项约40px,分隔符约10px)
|
||||
const itemHeight = 40;
|
||||
const dividerHeight = 10;
|
||||
const estimatedHeight = visibleItems.reduce((acc, item) => {
|
||||
return acc + (item.divider ? dividerHeight : itemHeight);
|
||||
}, 0) + 16; // +16 是上下 padding
|
||||
|
||||
const menuWidth = 250;
|
||||
const menuHeight = estimatedHeight + 20; // 额外的安全边距
|
||||
|
||||
// 初始位置:鼠标右侧下方
|
||||
let posX = x + 10;
|
||||
let posY = y + 10;
|
||||
|
||||
// 如果右侧空间不足,显示在左侧
|
||||
if (posX + menuWidth > window.innerWidth) {
|
||||
posX = x - menuWidth - 20;
|
||||
}
|
||||
|
||||
// 如果下方空间不足,固定到窗口底部(与参考实现一致)
|
||||
if (posY + menuHeight > window.innerHeight) {
|
||||
posY = window.innerHeight - menuHeight - 10;
|
||||
}
|
||||
|
||||
// 确保不会超出左边界
|
||||
if (posX < 10) {
|
||||
posX = 10;
|
||||
}
|
||||
|
||||
// 确保菜单顶部不会超出窗口
|
||||
if (posY < 10) {
|
||||
posY = 10;
|
||||
}
|
||||
|
||||
setPosition({ x: posX, y: posY });
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
// 监听右键事件
|
||||
useEffect(() => {
|
||||
if (!mindmap) return;
|
||||
|
||||
const handleContextMenu = (e: Event) => {
|
||||
const mouseEvent = e as MouseEvent;
|
||||
|
||||
// 检查是否点击在节点上
|
||||
const target = mouseEvent.target as HTMLElement | SVGElement;
|
||||
|
||||
// simple-mind-map 的节点结构
|
||||
// 尝试多种选择器
|
||||
const nodeSelectors = [
|
||||
".smm-node", // 主节点容器
|
||||
".smm-node-light", // 亮色主题节点
|
||||
"g[role='node']", // 带 role 属性的 g 元素
|
||||
"g.smooth-smooth", // 特定样式的 g 元素
|
||||
];
|
||||
|
||||
let clickedNodeEl: Element | null = null;
|
||||
for (const selector of nodeSelectors) {
|
||||
clickedNodeEl = target.closest?.(selector) || null;
|
||||
if (clickedNodeEl) break;
|
||||
}
|
||||
|
||||
// 如果没找到节点选择器,尝试查找包含 text 的元素
|
||||
if (!clickedNodeEl) {
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
// 检查父元素是否包含文本内容
|
||||
const textContainer = parent.querySelector("text");
|
||||
if (textContainer) {
|
||||
clickedNodeEl = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!clickedNodeEl) return;
|
||||
|
||||
// 阻止默认右键菜单
|
||||
mouseEvent.preventDefault();
|
||||
mouseEvent.stopPropagation();
|
||||
|
||||
// 获取当前激活的节点作为右键点击的节点
|
||||
const renderer = mindmap.renderer;
|
||||
if (!renderer) return;
|
||||
|
||||
// 使用 activeNodeList 或 lastActiveNodeList
|
||||
const activeList = renderer.activeNodeList ?? [];
|
||||
const lastActiveList = renderer.lastActiveNodeList ?? [];
|
||||
|
||||
const node = (activeList[0] ?? lastActiveList[0] ?? null) as MindMapNode | null;
|
||||
|
||||
if (node) {
|
||||
show(mouseEvent.clientX, mouseEvent.clientY, node);
|
||||
}
|
||||
};
|
||||
|
||||
// 延迟查找容器,确保 DOM 已经渲染
|
||||
const timer = setTimeout(() => {
|
||||
const container = document.querySelector("[data-testid='mindmap-canvas']");
|
||||
if (container) {
|
||||
container.addEventListener("contextmenu", handleContextMenu, true);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
const container = document.querySelector("[data-testid='mindmap-canvas']");
|
||||
if (container) {
|
||||
container.removeEventListener("contextmenu", handleContextMenu, true);
|
||||
}
|
||||
};
|
||||
}, [mindmap, show]);
|
||||
|
||||
// 监听画布点击事件隐藏菜单
|
||||
useEffect(() => {
|
||||
if (!mindmap) return;
|
||||
|
||||
const hideMenu = () => {
|
||||
hide();
|
||||
};
|
||||
|
||||
mindmap.on?.("draw_click", hideMenu);
|
||||
mindmap.on?.("node_click", hideMenu);
|
||||
mindmap.on?.("expand_btn_click", hideMenu);
|
||||
|
||||
return () => {
|
||||
mindmap.off?.("draw_click", hideMenu);
|
||||
mindmap.off?.("node_click", hideMenu);
|
||||
mindmap.off?.("expand_btn_click", hideMenu);
|
||||
};
|
||||
}, [mindmap, hide]);
|
||||
|
||||
// 点击外部隐藏菜单
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
hide();
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
hide();
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [visible, hide]);
|
||||
|
||||
// 渲染菜单
|
||||
const renderMenu = () => {
|
||||
const visibleItems = getVisibleItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="mindmap-contextmenu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
zIndex: 9999,
|
||||
minWidth: "200px",
|
||||
maxWidth: "280px",
|
||||
background: "#ffffff",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.15)",
|
||||
padding: "8px 0",
|
||||
fontSize: "14px",
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{visibleItems.map((item, index) => {
|
||||
if (item.divider) {
|
||||
return (
|
||||
<div
|
||||
key={`divider-${index}`}
|
||||
style={{
|
||||
height: "1px",
|
||||
background: "#e5e7eb",
|
||||
margin: "4px 12px",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const disabled = isItemDisabled(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key || `item-${index}`}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
if (!item.key) return;
|
||||
executeCommand(item.key);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "8px 16px",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
color: item.danger ? "#ef4444" : disabled ? "#9ca3af" : "#1f2937",
|
||||
background: "transparent",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.background = "#f3f4f6";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "14px" }}>{item.label || ""}</span>
|
||||
{item.shortcut && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#9ca3af",
|
||||
marginLeft: "24px",
|
||||
}}
|
||||
>
|
||||
{item.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (typeof document === "undefined" || !visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(renderMenu(), document.body);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ export type MindMapNode = {
|
||||
getStyle: (key: string, checkRoot?: boolean) => unknown;
|
||||
setStyle?: (key: string, value: unknown) => void;
|
||||
setIcon?: (icons: string[]) => void;
|
||||
setImage?: (img: { url: string; width?: number; height?: number } | null) => void;
|
||||
setImage?: (img: { url: string; title?: string; width?: number; height?: number } | null) => void;
|
||||
getData: (key: string) => unknown;
|
||||
nodeData?: { data?: Record<string, unknown> };
|
||||
data?: Record<string, unknown>;
|
||||
@@ -10,5 +10,25 @@ export type MindMapNode = {
|
||||
parent?: { children?: unknown[] } | null;
|
||||
children?: unknown[];
|
||||
uid?: string;
|
||||
isRoot?: boolean;
|
||||
isGeneralization?: boolean;
|
||||
setHyperlink?: (url: string, title: string) => void;
|
||||
setNote?: (note: string) => void;
|
||||
};
|
||||
|
||||
// 思维导图实例:这里仅声明本项目用到的最小形状,避免到处用 any
|
||||
export type MindMapInstance = {
|
||||
renderer?: {
|
||||
activeNodeList?: MindMapNode[];
|
||||
lastActiveNodeList?: MindMapNode[];
|
||||
copy?: () => void;
|
||||
cut?: () => void;
|
||||
paste?: () => void;
|
||||
};
|
||||
execCommand?: (command: string, ...args: unknown[]) => void;
|
||||
doExport?: { export: (type: string, ...args: unknown[]) => void };
|
||||
on?: (event: string, fn: (...args: unknown[]) => void) => void;
|
||||
off?: (event: string, fn: (...args: unknown[]) => void) => void;
|
||||
setData?: (data: unknown) => void;
|
||||
command?: { clearHistory?: () => void };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user