双向删除同步

This commit is contained in:
liaibo
2026-01-02 07:25:50 +08:00
parent 1db64c1c55
commit b1288487af
48 changed files with 2406 additions and 8841 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"permissions": {
"allow": [
"mcp__chrome-devtools__list_pages",
"mcp__chrome-devtools__navigate_page",
"mcp__chrome-devtools__take_snapshot",
"mcp__chrome-devtools__list_console_messages",
"mcp__chrome-devtools__handle_dialog",
"mcp__chrome-devtools__get_console_message",
"mcp__chrome-devtools__click",
"mcp__chrome-devtools__fill"
]
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"supabase_local",
"chrome-devtools",
"context7"
]
}
+33
View File
@@ -0,0 +1,33 @@
{
"mcpServers": {
"supabase_local": {
"type": "stdio",
"command": "cmd",
"args": ["/c", "npx", "-y", "mcp-remote@latest", "http://127.0.0.1:18000/mcp"],
"startup_timeout_ms": 60000,
"env": {
"PROGRAMFILES": "C:\\Program Files",
"SystemRoot": "C:\\Windows"
}
},
"chrome-devtools": {
"type": "stdio",
"command": "cmd",
"args": ["/c", "npx", "-y", "chrome-devtools-mcp@latest"],
"startup_timeout_ms": 60000,
"env": {
"PROGRAMFILES": "C:\\Program Files",
"SystemRoot": "C:\\Windows"
}
},
"context7": {
"type": "stdio",
"command": "cmd",
"args": ["/c", "npx", "-y", "@upstash/context7-mcp", "--api-key", "ctx7sk-8beae3e8-38a0-497a-a44d-a67c05669318"],
"startup_timeout_ms": 40000,
"env": {
"SystemRoot": "C:\\Windows"
}
}
}
}
+22 -8641
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
const { transformMarkdownTo } = require('simple-mind-map/src/parse/markdownTo.js');
// 模拟 MindmapSidebar.tsx 中的逻辑
async function testAiContinuation() {
const text = `
- 子节点1
- 子节点2
`;
console.log("解析 markdown...");
let tree = transformMarkdownTo(text);
let children = tree?.children || [];
if (!children.length) {
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
const listLines = lines.filter((l) => /^[-*+]\s+/.test(l)).map((l) => l.replace(/^[-*+]\s+/, ""));
children = listLines.map((t) => ({ data: { text: t } }));
}
console.log("解析结果 children:", JSON.stringify(children, null, 2));
// 模拟深拷贝修复
children = JSON.parse(JSON.stringify(children));
console.log("深拷贝后 children:", JSON.stringify(children, null, 2));
// 模拟添加到节点
const mockNode = {
data: { uid: 'test-uid', text: 'Root' },
children: []
};
const appendChildren = (node) => {
if (node.data.uid === 'test-uid') {
// 这里模拟多次添加,检查是否有引用问题
node.children.push(...children);
// 如果 children 是同一个引用,再次 push 会导致问题吗?
// 在实际 dom/proxy 环境中,直接 push 可能会有问题,但这里主要是测试逻辑正确性
}
};
appendChildren(mockNode);
console.log("添加后节点:", JSON.stringify(mockNode, null, 2));
}
testAiContinuation().catch(console.error);
@@ -0,0 +1 @@
# 无标题
@@ -0,0 +1,33 @@
{
"data": {
"text": "<p>中心主题</p>",
"expand": true,
"uid": "77429175-1db4-4f50-850b-a49ec688204d",
"richText": true,
"isActive": false,
"imgMap": {}
},
"children": [
{
"data": {
"text": "<p>二级节点</p>",
"uid": "1e089848-4985-483c-9d80-ef1a3865a592",
"expand": true,
"richText": true,
"isActive": false
},
"children": []
},
{
"data": {
"text": "<p>二级节点</p>",
"expand": true,
"richText": true,
"isActive": false,
"uid": "8c50f0da-cf3b-4677-a4bb-468689ccdc5c"
},
"children": []
}
],
"smmVersion": "0.14.0-fix.1"
}
@@ -0,0 +1 @@
# 无标题
@@ -0,0 +1,33 @@
{
"data": {
"text": "<p>中心主题</p>",
"expand": true,
"uid": "af0d1e06-4523-4482-b713-1f42d7ac4d50",
"richText": true,
"isActive": false,
"imgMap": {}
},
"children": [
{
"data": {
"text": "<p>二级节点</p>",
"uid": "0691f32c-e9a3-46ed-b12e-d1e1c90cf5b3",
"expand": true,
"richText": true,
"isActive": false
},
"children": []
},
{
"data": {
"text": "<p>二级节点</p>",
"expand": true,
"richText": true,
"isActive": false,
"uid": "17d61971-1835-40f8-9aa2-a42d1428e657"
},
"children": []
}
],
"smmVersion": "0.14.0-fix.1"
}
@@ -0,0 +1 @@
# 无标题
@@ -0,0 +1 @@
# 无标题
@@ -0,0 +1,12 @@
{
"data": {
"text": "<p>中心主题</p>",
"expand": true,
"uid": "34705f53-b718-4c5e-9678-a5a1c614e13f",
"richText": true,
"isActive": false,
"imgMap": {}
},
"children": [],
"smmVersion": "0.14.0-fix.1"
}
@@ -0,0 +1 @@
# 无标题
@@ -0,0 +1,23 @@
{
"data": {
"uid": "42bf146a-be38-438e-9494-83276b164206",
"text": "<p>中心主题</p>",
"expand": true,
"imgMap": {},
"isActive": false,
"richText": true
},
"children": [
{
"data": {
"uid": "0e3400fe-397a-4213-a31d-c0be2977a073",
"text": "<p>二级节点</p>",
"expand": true,
"isActive": false,
"richText": true
},
"children": []
}
],
"smmVersion": "0.14.0-fix.1"
}
@@ -0,0 +1 @@
# 无标题
@@ -0,0 +1,12 @@
{
"data": {
"uid": "c82f7636-ff8f-4bc2-b370-660eb2e22424",
"text": "<p>中心主题</p>",
"expand": true,
"imgMap": {},
"isActive": false,
"richText": true
},
"children": [],
"smmVersion": "0.14.0-fix.1"
}
@@ -0,0 +1 @@
# 无标题
@@ -0,0 +1 @@
# 无标题
@@ -0,0 +1,23 @@
{
"data": {
"text": "<p>中心主题</p>",
"expand": true,
"uid": "070797ac-3f52-4667-898b-1c9a5293f7c8",
"richText": true,
"isActive": false,
"imgMap": {}
},
"children": [
{
"data": {
"text": "<p>二级节点</p>",
"uid": "e299f77b-0056-4b96-8639-da429d20a9b5",
"expand": true,
"richText": true,
"isActive": false
},
"children": []
}
],
"smmVersion": "0.14.0-fix.1"
}
@@ -0,0 +1,44 @@
{
"data": {
"text": "<p><span>中心主题</span></p>",
"expand": true,
"uid": "10f2ce97-0e5d-483b-b1d0-3ba8f516a86c",
"richText": true,
"isActive": false,
"imgMap": {}
},
"children": [
{
"data": {
"text": "<p>二级节点</p>",
"uid": "109ee78f-52b2-4adb-a1b6-979588ddcf4e",
"expand": true,
"richText": true,
"isActive": false
},
"children": [
{
"data": {
"text": "<p>分支主题</p>",
"uid": "dad8dada-77a0-4e6e-ad1b-0eeb723cff21",
"expand": true,
"richText": true,
"isActive": false
},
"children": []
}
]
},
{
"data": {
"text": "<p>二级节点</p>",
"expand": true,
"richText": true,
"isActive": false,
"uid": "9372a636-262d-4e6b-b899-7b269fec0399"
},
"children": []
}
],
"smmVersion": "0.14.0-fix.1"
}
@@ -0,0 +1 @@
# 无标题
@@ -0,0 +1,12 @@
{
"data": {
"text": "<p>中心主题</p>",
"expand": true,
"uid": "c01b4134-a1df-491d-ad5d-0a245d53999a",
"richText": true,
"isActive": false,
"imgMap": {}
},
"children": [],
"smmVersion": "0.14.0-fix.1"
}
@@ -0,0 +1 @@
# 无标题
@@ -0,0 +1,12 @@
{
"data": {
"text": "<p>中心主题</p>",
"expand": true,
"uid": "c3f48437-86f0-4a0d-bec6-12cf3530ccdf",
"richText": true,
"isActive": false,
"imgMap": {}
},
"children": [],
"smmVersion": "0.14.0-fix.1"
}
@@ -0,0 +1,44 @@
{
"data": {
"text": "<p><span>中心主题</span></p>",
"expand": true,
"uid": "10f2ce97-0e5d-483b-b1d0-3ba8f516a86c",
"richText": true,
"isActive": false,
"imgMap": {}
},
"children": [
{
"data": {
"text": "<p>二级节点</p>",
"uid": "109ee78f-52b2-4adb-a1b6-979588ddcf4e",
"expand": true,
"richText": true,
"isActive": false
},
"children": [
{
"data": {
"text": "<p>分支主题</p>",
"uid": "dad8dada-77a0-4e6e-ad1b-0eeb723cff21",
"expand": true,
"richText": true,
"isActive": false
},
"children": []
}
]
},
{
"data": {
"text": "<p>二级节点</p>",
"expand": true,
"richText": true,
"isActive": false,
"uid": "9372a636-262d-4e6b-b899-7b269fec0399"
},
"children": []
}
],
"smmVersion": "0.14.0-fix.1"
}
+8
View File
@@ -10,6 +10,7 @@ import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspace
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { SearchPalette } from "@/components/search/search-palette";
import { detectLocalMindmapDocs } from "@/lib/mindmap-files";
export default async function AppLayout({ children }: { children: ReactNode }) {
const supabase = await createSupabaseServerClient();
@@ -30,12 +31,19 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
if (activeWorkspaceId) {
const dataset = await fetchSidebarDataset(supabase, activeWorkspaceId);
documents = dataset.documents;
const localMindmaps = await detectLocalMindmapDocs(
dataset.documents.map((d) => d.id),
);
const mindmapDocs = Array.from(
new Set([...(dataset.mindmapDocs ?? []), ...localMindmaps]),
);
sidebarInitialData = {
activeWorkspaceId,
workspaces,
documents: dataset.documents,
trashedDocuments: dataset.trashedDocuments,
mediaAssets: dataset.mediaAssets,
mindmapDocs,
};
}
@@ -4,6 +4,23 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
import type { Json } from "@/types/supabase";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import path from "path";
import { promises as fs } from "fs";
const documentsBaseDir = path.join(process.cwd(), "public", "documents");
async function ensureDocumentScaffold(id: string, title: string | null) {
const folder = path.join(documentsBaseDir, id);
const indexFile = path.join(folder, "index.md");
await fs.mkdir(folder, { recursive: true });
try {
await fs.access(indexFile);
} catch {
const safeTitle = title && title.trim() ? title.trim() : "无标题";
const content = `# ${safeTitle}\n`;
await fs.writeFile(indexFile, content, "utf8");
}
}
export async function POST(request: Request) {
try {
@@ -95,6 +112,11 @@ async function handleCreateRequest(request: Request) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
// 为文件树创建本地目录和 index.md
if (data?.id) {
await ensureDocumentScaffold(data.id, data.title ?? "无标题");
}
if (parentId && data) {
const existingBlocks = extractBlocksFromContent(parentContent);
const pageReferenceBlock = {
@@ -1,10 +1,36 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import path from "path";
import { promises as fs } from "fs";
interface DuplicatePayload {
documentId: string;
}
const documentsBaseDir = path.join(process.cwd(), "public", "documents");
async function ensureDocumentScaffold(id: string, title: string | null) {
const folder = path.join(documentsBaseDir, id);
const indexFile = path.join(folder, "index.md");
await fs.mkdir(folder, { recursive: true });
const safeTitle = title && title.trim() ? title.trim() : "无标题";
const content = `# ${safeTitle}\n`;
await fs.writeFile(indexFile, content, "utf8");
}
async function copyMindmapIfExists(sourceId: string, targetId: string) {
const src = path.join(documentsBaseDir, sourceId, "mindmap.json");
const destDir = path.join(documentsBaseDir, targetId);
const dest = path.join(destDir, "mindmap.json");
try {
const buf = await fs.readFile(src);
await fs.mkdir(destDir, { recursive: true });
await fs.writeFile(dest, buf);
} catch {
// 如果源不存在则忽略
}
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
@@ -16,7 +42,6 @@ export async function POST(request: Request) {
}
const { documentId }: DuplicatePayload = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
@@ -44,12 +69,14 @@ export async function POST(request: Request) {
}
const { count: siblingCount = 0, error: countError } = await siblingQuery;
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 500 });
}
const fallbackTitle = sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0 ? sourceDoc.title.trim() : "无标题";
const fallbackTitle =
sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0
? sourceDoc.title.trim()
: "无标题";
const duplicatedTitle = `${fallbackTitle} 副本`;
const { data: duplicated, error: duplicateError } = await supabase
@@ -67,8 +94,14 @@ export async function POST(request: Request) {
.single();
if (duplicateError || !duplicated) {
return NextResponse.json({ error: duplicateError?.message ?? "复制失败" }, { status: 500 });
return NextResponse.json(
{ error: duplicateError?.message ?? "复制失败" },
{ status: 500 },
);
}
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
await copyMindmapIfExists(sourceDoc.id, duplicated.id);
return NextResponse.json(duplicated);
}
@@ -0,0 +1,158 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { promises as fs } from "fs";
import path from "path";
const defaultMindmapData = {
data: { text: "中心主题" },
children: [],
};
// 新版:与页面文件夹(index.md 所在处)对齐,放在 public/documents/<docId>/mindmap.json
// 旧版遗留:public/mindmaps/<docId>/mindmap.json
const preferredBaseDir = path.join(process.cwd(), "public", "documents");
const legacyBaseDir = path.join(process.cwd(), "public", "mindmaps");
async function ensureDir(dir: string) {
await fs.mkdir(dir, { recursive: true });
}
async function removeFileSafe(file: string) {
try {
await fs.rm(file, { force: true });
} catch {
// 忽略删除失败(例如文件不存在)
}
}
async function ensureIndexFile(folder: string, title = "无标题") {
const indexFile = path.join(folder, "index.md");
try {
await fs.access(indexFile);
} catch {
await fs.writeFile(indexFile, `# ${title}\n`, "utf8");
}
}
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const preferredFolder = path.join(preferredBaseDir, id);
const preferredFile = path.join(preferredFolder, "mindmap.json");
const legacyFolder = path.join(legacyBaseDir, id);
const legacyFile = path.join(legacyFolder, "mindmap.json");
const tryRead = async (file: string) => {
try {
const content = await fs.readFile(file, "utf8");
return JSON.parse(content);
} catch {
return null;
}
};
const localData =
(await tryRead(preferredFile)) ??
(await tryRead(legacyFile));
if (localData) {
return NextResponse.json({ data: localData, source: "local" });
}
// fallback Supabase
const { data, error } = await supabase
.from("documents")
.select("mindmap_data")
.eq("id", id)
.eq("user_id", session.user.id)
.maybeSingle();
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
const payload = data?.mindmap_data ?? defaultMindmapData;
return NextResponse.json({ data: payload, source: "supabase" });
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { data } = await request.json();
const folder = path.join(preferredBaseDir, id);
const file = path.join(folder, "mindmap.json");
try {
await ensureDir(folder);
await ensureIndexFile(folder);
await fs.writeFile(
file,
JSON.stringify(data ?? defaultMindmapData, null, 2),
"utf8",
);
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
const { error } = await supabase
.from("documents")
.update({ mindmap_data: data ?? defaultMindmapData })
.eq("id", id)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json({ ok: true });
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const preferredFolder = path.join(preferredBaseDir, id);
const preferredFile = path.join(preferredFolder, "mindmap.json");
const legacyFolder = path.join(legacyBaseDir, id);
const legacyFile = path.join(legacyFolder, "mindmap.json");
await Promise.all([removeFileSafe(preferredFile), removeFileSafe(legacyFile)]);
const { error } = await supabase
.from("documents")
.update({ mindmap_data: null })
.eq("id", id)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
@@ -3,6 +3,7 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspaces";
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { detectLocalMindmapDocs } from "@/lib/mindmap-files";
export const dynamic = "force-dynamic";
@@ -29,12 +30,20 @@ export async function GET(request: Request) {
try {
const dataset = await fetchSidebarDataset(supabase, targetWorkspaceId);
const localMindmaps = await detectLocalMindmapDocs(
dataset.documents.map((d) => d.id),
);
const mindmapDocs = Array.from(
new Set([...(dataset.mindmapDocs ?? []), ...localMindmaps]),
);
const payload: SidebarInitialData = {
activeWorkspaceId: targetWorkspaceId,
workspaces,
documents: dataset.documents,
trashedDocuments: dataset.trashedDocuments,
mindmapDocs,
mediaAssets: dataset.mediaAssets ?? [],
};
return NextResponse.json(payload);
@@ -3,9 +3,9 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { DocumentTableSnapshot, TableRowData } from "@/types/online-table";
interface RouteContext {
params: {
params: Promise<{
tableId: string;
};
}>;
}
type UpdateTableRequest = {
@@ -19,9 +19,7 @@ type UpdateTableRequest = {
};
const extractTableId = async (context: RouteContext) => {
// Next.js 16 / Turbopack 下 params 是 Promise,需要等待。
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (await (context as any).params).tableId as string;
return (await context.params).tableId;
};
const getAuthUser = async () => {
@@ -31,7 +29,7 @@ const getAuthUser = async () => {
console.error("Auth getUser error:", error);
}
if (!data?.user) {
return { supabase, user: null as const };
return { supabase, user: null };
}
return { supabase, user: data.user };
};
@@ -4,7 +4,7 @@ import "@blocknote/core/style.css";
import "@blocknote/react/style.css";
import "@blocknote/mantine/style.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BlockNoteView } from "@blocknote/mantine";
import {
SideMenuController,
@@ -27,6 +27,7 @@ import { useSearchPaletteStore } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import type { ReferenceTarget } from "@/types/search";
import FullScreenTableEditor from "@/components/online-table/FullScreenTableEditor";
import { ASSETS_CHANGED_EVENT, emitAssetsChanged } from "@/lib/events";
interface BlockNoteEditorProps {
documentId: string;
@@ -235,6 +236,98 @@ export function BlockNoteEditor({
);
const debouncedSave = useDebouncedCallback(saveContent, 800);
const previousAssetsRef = useRef<Set<string>>(new Set());
const hadMindmapRef = useRef(false);
const collectAssets = useCallback((blocks: Block<CustomBlockSchema>[]) => {
const assetIds = new Set<string>();
let hasMindmap = false;
const walk = (target: Block<CustomBlockSchema>[]) => {
target.forEach((b) => {
if (b.type === "media") {
const id = (b.props as { assetId?: string })?.assetId;
if (id) assetIds.add(id);
}
if (b.type === "mindmap") {
hasMindmap = true;
}
if (Array.isArray(b.children) && b.children.length > 0) {
walk(b.children as Block<CustomBlockSchema>[]);
}
});
};
walk(blocks);
return { assetIds, hasMindmap };
}, []);
const deleteAssets = useCallback(
async (assetIds: string[]) => {
if (assetIds.length === 0) return;
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds }),
});
if (!resp.ok) {
// 如果后端返回未找到,说明已被其他端删除,忽略即可
if (resp.status !== 404) {
console.error("删除附件失败", await resp.text());
}
return;
}
emitAssetsChanged(documentId);
},
[documentId],
);
const deleteMindmap = useCallback(async () => {
const resp = await fetch(`/api/mindmap/${documentId}`, { method: "DELETE" });
if (!resp.ok) {
console.error("删除思维导图失败", await resp.text());
return;
}
emitAssetsChanged(documentId);
}, [documentId]);
// 监听侧边栏删除事件,主动移除编辑区遗留块
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent)?.detail as {
docId?: string;
assetIds?: string[];
mindmapDeleted?: boolean;
};
if (!detail || detail.docId !== documentId) return;
const assetIds = detail.assetIds ?? [];
const mindmapDeleted = Boolean(detail.mindmapDeleted);
if (assetIds.length === 0 && !mindmapDeleted) return;
const blocks = editor?.topLevelBlocks as Block<CustomBlockSchema>[] | undefined;
if (!blocks || blocks.length === 0 || !editor) return;
const toRemove: string[] = [];
const walk = (target: Block<CustomBlockSchema>[]) => {
target.forEach((b) => {
if (mindmapDeleted && b.type === "mindmap") {
toRemove.push(b.id);
}
if (assetIds.length > 0 && b.type === "media") {
const id = (b.props as { assetId?: string })?.assetId;
if (id && assetIds.includes(id)) {
toRemove.push(b.id);
}
}
if (Array.isArray(b.children) && b.children.length > 0) {
walk(b.children as Block<CustomBlockSchema>[]);
}
});
};
walk(blocks);
if (toRemove.length > 0) {
editor.removeBlocks(toRemove);
}
};
window.addEventListener(ASSETS_CHANGED_EVENT, handler);
return () => window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
}, [documentId, editor]);
useEffect(() => {
if (!editor) {
@@ -254,6 +347,19 @@ export function BlockNoteEditor({
const stats = computeDocumentStats(typedBlocks);
onStatsChange?.(stats);
onSnapshot?.({ blocks: blocks as Json, stats });
// 检测块删除 -> 同步删除附件/思维导图并刷新侧边栏
const { assetIds, hasMindmap } = collectAssets(typedBlocks);
const prevAssets = previousAssetsRef.current;
const removedAssets = [...prevAssets].filter((id) => !assetIds.has(id));
if (removedAssets.length > 0) {
void deleteAssets(removedAssets);
}
previousAssetsRef.current = assetIds;
if (hadMindmapRef.current && !hasMindmap) {
void deleteMindmap();
}
hadMindmapRef.current = hasMindmap;
};
runSync();
@@ -264,7 +370,7 @@ export function BlockNoteEditor({
unsubscribe();
}
};
}, [editor, debouncedSave, onSnapshot, onStatsChange]);
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmap, editor, onSnapshot, onStatsChange]);
const jumpToHeading = useCallback((headingId: string) => {
const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
@@ -386,6 +492,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
fileSize: asset.file_size ?? null,
mimeType: asset.mime_type ?? "",
ocrStatus: asset.ocr_status ?? "idle",
documentId,
},
},
],
@@ -393,7 +500,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
"after",
);
},
[editor],
[documentId, editor],
);
const uploadClipboardMedia = useCallback(
@@ -417,6 +524,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
if (!payload.asset) {
throw new Error("上传返回数据缺失");
}
emitAssetsChanged(documentId, payload.asset);
return payload.asset;
},
[documentId, workspaceId],
@@ -5,16 +5,18 @@ import {
useRef,
useState,
useMemo,
useCallback,
type JSX,
type MouseEvent as ReactMouseEvent,
} from "react";
import { createReactBlockSpec } from "@blocknote/react";
import type { Block, BlockNoteEditor } from "@blocknote/core";
import { Download, Image as ImageIcon, Link as LinkIcon, MoreHorizontal, Paperclip, RefreshCcw, Type } from "lucide-react";
import { Download, Image as ImageIcon, Link as LinkIcon, MoreHorizontal, Paperclip, RefreshCcw, Trash, Type } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useImagePicker } from "@/components/media/image-picker-context";
import type { MediaKind } from "@/types/media";
import { emitAssetsChanged } from "@/lib/events";
import {
DropdownMenu,
DropdownMenuContent,
@@ -96,6 +98,16 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
const [captionEditing, setCaptionEditing] = useState(false);
const shouldShowCaption = captionEditing || Boolean(block.props.caption);
const officeBase = process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL;
const resolveDocumentId = useCallback(() => {
if (typeof window !== "undefined") {
const [, tail] = window.location.pathname.split("/documents/");
if (tail) {
const id = tail.split(/[/?#]/)[0];
if (id) return id;
}
}
return (block.props as { documentId?: string })?.documentId || "";
}, [block.props]);
const extension = useMemo(() => {
const name = (block.props.fileName || deriveFileName(fileUrl)).toLowerCase();
const match = /\.([a-z0-9]+)$/.exec(name);
@@ -124,6 +136,7 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
fileSize: selection.fileSize ?? block.props.fileSize ?? null,
mimeType: selection.mimeType ?? block.props.mimeType ?? "",
ocrStatus: "idle",
documentId: resolveDocumentId(),
},
});
},
@@ -255,6 +268,27 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
anchor.click();
};
const handleDeleteAsset = async () => {
const assetId = (block.props as { assetId?: string })?.assetId;
if (!assetId) {
editor.removeBlocks([block.id]);
return;
}
const docId = resolveDocumentId();
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds: [assetId] }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除附件失败");
return;
}
editor.removeBlocks([block.id]);
emitAssetsChanged(docId);
};
const triggerOcr = async () => {
if (!block.props.assetId) {
window.alert("请先上传图片后再执行 OCR");
@@ -395,6 +429,12 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
icon: <Download className="h-4 w-4" />,
onClick: downloadAsset,
},
{
key: "delete",
label: `删除${typeLabel}`,
icon: <Trash className="h-4 w-4" />,
onClick: handleDeleteAsset,
},
].filter((action): action is QuickAction => Boolean(action));
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
@@ -504,6 +544,7 @@ export const mediaBlock = createReactBlockSpec(
mimeType: { default: "", type: "string" },
width: { default: 0, type: "number" },
ocrStatus: { default: "idle", type: "string" },
documentId: { default: "", type: "string" },
},
content: "none",
},
@@ -17,9 +17,15 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from "lucide-react";
import iconConfig from "./mindmapIconConfig";
import { nodeIconList } from "simple-mind-map/src/svg/icons.js";
import { emitAssetsChanged } from "@/lib/events";
// 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document
// @ts-expect-error 第三方库缺少类型定义
import { mergerIconList } from "simple-mind-map/src/utils/index.js";
const loadIconModules = async () => {
const { nodeIconList } = await import("simple-mind-map/src/svg/icons.js");
const { mergerIconList } = await import("simple-mind-map/src/utils/index.js");
return { nodeIconList, mergerIconList };
};
// 安全获取图片尺寸
const getImageSizeSafe = (url: string): Promise<{ width: number; height: number } | null> =>
@@ -54,7 +60,19 @@ type MindMapInstance = {
setLayout: (layout: string) => void;
};
const defaultMindmapData = {
type MindMapNode = {
getStyle?: (key: string, inherit?: boolean) => unknown;
getData?: (key: string) => unknown;
nodeData?: { data?: Record<string, unknown> };
data?: Record<string, unknown>;
};
type MindMapData = {
data: Record<string, unknown>;
children?: unknown[];
};
export const defaultMindmapData = {
data: { text: "中心主题" },
children: [],
};
@@ -77,6 +95,39 @@ const createSimplePrompt = (title: string, placeholder = "") => {
return value.trim();
};
// 修补 svg.js rbox 在节点未挂载时抛出的异常
const patchSvgRbox = async () => {
// 仅在浏览器环境生效
if (typeof window === "undefined") return;
const svgModule = await import("@svgdotjs/svg.js");
const candidates = [(svgModule as any).Element, (svgModule as any).G];
candidates.forEach((Ctor) => {
if (!Ctor?.prototype) return;
if (Ctor.prototype.__wolaiRboxPatched) return;
const original = Ctor.prototype.rbox;
if (typeof original !== "function") return;
// @ts-expect-error 动态扩展第三方原型
Ctor.prototype.rbox = function patchedRbox(ref?: unknown) {
try {
return original.call(this, ref);
} catch (error) {
console.warn("rbox 失败,返回空边界框以避免崩溃", error);
return {
x: 0,
y: 0,
width: 0,
height: 0,
x2: 0,
y2: 0,
cx: 0,
cy: 0,
};
}
};
Ctor.prototype.__wolaiRboxPatched = true;
});
};
const applyToActiveNodes = (
mindmap: MindMapInstance | null,
handler: (node: unknown) => void,
@@ -134,7 +185,6 @@ const MindmapBlockView = ({
const [canBack, setCanBack] = useState(false);
const [canForward, setCanForward] = useState(false);
const [activeNodes, setActiveNodes] = useState<unknown[]>([]);
const activeCount = activeNodes.length;
const [painterMode, setPainterMode] = useState(false);
const [showMiniMap, setShowMiniMap] = useState(false);
const [activeSidebar, setActiveSidebar] = useState<SidebarPanel | null>(null);
@@ -146,7 +196,19 @@ const MindmapBlockView = ({
return instance?.renderer?.root ?? instance?.renderer?.renderTree?._node ?? null;
}, [mindmap]);
const autosaveKey = useMemo(() => `${STORAGE_PREFIX}${block.id}`, [block.id]);
const docId = useMemo(
() =>
block.props.docId ||
(typeof window !== "undefined"
? window.location.pathname.split("/").pop() ?? ""
: ""),
[block.props.docId],
);
const autosaveKey = useMemo(
() => `${STORAGE_PREFIX}${docId || block.id}`,
[block.id, docId],
);
const initialDataRef = useRef<unknown>(null);
if (initialDataRef.current === null) {
const cached = typeof window !== "undefined" ? window.localStorage.getItem(autosaveKey) : null;
@@ -161,19 +223,76 @@ const MindmapBlockView = ({
}
}
// 优先加载本地文件,其次 Supabase(通过后端 API
useEffect(() => {
let cancelled = false;
if (!docId) return;
(async () => {
try {
const resp = await fetch(`/api/mindmap/${docId}`);
if (!resp.ok) return;
const payload = await resp.json().catch(() => null);
const data = payload?.data;
if (!data || cancelled) return;
initialDataRef.current = data;
if (mindmap) {
mindmap.setData(data);
mindmap.command.clearHistory();
}
} catch (error) {
console.warn("加载本地/远端思维导图失败", error);
}
})();
return () => {
cancelled = true;
};
}, [docId, mindmap]);
const persistData = useCallback(
(data: unknown) => {
if (!editor) return;
window.localStorage.setItem(autosaveKey, JSON.stringify(data));
editor.updateBlock(block, { props: { ...block.props, data } });
if (docId) {
// 同步到本地文件 + Supabase(弱依赖)
fetch(`/api/mindmap/${docId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data }),
})
.then((resp) => {
if (resp.ok) {
emitAssetsChanged(docId, { id: `mindmap-${docId}`, document_id: docId, asset_type: "mindmap" });
}
})
.catch((err) => console.warn("思维导图同步失败", err));
}
},
[autosaveKey, block, editor],
[autosaveKey, block, docId, editor],
);
const debouncedPersist = useDebouncedCallback((data: unknown) => {
persistData(data);
}, 500);
const initialSyncDone = useRef(false);
useEffect(() => {
if (!docId || !mindmap || initialSyncDone.current) return;
initialSyncDone.current = true;
const data = mindmap.getData?.() ?? initialDataRef.current ?? defaultMindmapData;
void fetch(`/api/mindmap/${docId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data }),
})
.then((resp) => {
if (resp.ok) {
emitAssetsChanged(docId, { id: `mindmap-${docId}`, document_id: docId, asset_type: "mindmap" });
}
})
.catch((err) => console.warn("初次创建思维导图文件失败", err));
}, [docId, mindmap, initialDataRef]);
// 初始选中根节点,后续不强制抢焦点,允许用户自由选择
useEffect(() => {
if (!mindmap || activeNodes.length > 0) return;
@@ -204,6 +323,16 @@ const MindmapBlockView = ({
{ default: Drag },
{ default: KeyboardNavigation },
{ default: NodeImgAdjust },
{ default: Scrollbar },
{ default: RainbowLines },
{ default: Watermark },
{ default: TouchEvent },
{ default: Cooperate },
{ default: Demonstrate },
{ default: MindMapLayoutPro },
{ default: NodeBase64ImageStorage },
{ default: ExportPDF },
{ default: ExportXMind },
] = await Promise.all([
import("simple-mind-map"),
import("simple-mind-map/src/plugins/Painter.js"),
@@ -217,6 +346,16 @@ const MindmapBlockView = ({
import("simple-mind-map/src/plugins/Drag.js"),
import("simple-mind-map/src/plugins/KeyboardNavigation.js"),
import("simple-mind-map/src/plugins/NodeImgAdjust.js"),
import("simple-mind-map/src/plugins/Scrollbar.js"),
import("simple-mind-map/src/plugins/RainbowLines.js"),
import("simple-mind-map/src/plugins/Watermark.js"),
import("simple-mind-map/src/plugins/TouchEvent.js"),
import("simple-mind-map/src/plugins/Cooperate.js"),
import("simple-mind-map/src/plugins/Demonstrate.js"),
import("simple-mind-map/src/plugins/MindMapLayoutPro.js"),
import("simple-mind-map/src/plugins/NodeBase64ImageStorage.js"),
import("simple-mind-map/src/plugins/ExportPDF.js"),
import("simple-mind-map/src/plugins/ExportXMind.js"),
]);
const plugins = [
@@ -231,6 +370,16 @@ const MindmapBlockView = ({
{ name: "Drag", plugin: Drag },
{ name: "KeyboardNavigation", plugin: KeyboardNavigation },
{ name: "NodeImgAdjust", plugin: NodeImgAdjust },
{ name: "Scrollbar", plugin: Scrollbar },
{ name: "RainbowLines", plugin: RainbowLines },
{ name: "Watermark", plugin: Watermark },
{ name: "TouchEvent", plugin: TouchEvent },
{ name: "Cooperate", plugin: Cooperate },
{ name: "Demonstrate", plugin: Demonstrate },
{ name: "MindMapLayoutPro", plugin: MindMapLayoutPro },
{ name: "NodeBase64ImageStorage", plugin: NodeBase64ImageStorage },
{ name: "ExportPDF", plugin: ExportPDF },
{ name: "ExportXMind", plugin: ExportXMind },
];
plugins.forEach(({ name, plugin }) => {
@@ -241,13 +390,23 @@ const MindmapBlockView = ({
// @ts-expect-error 第三方库缺少类型定义
if (MindMap.hasPlugin(plugin) === -1) {
console.log(`注册插件: ${name}`);
// @ts-expect-error 第三方库缺少类型定义
MindMap.usePlugin(plugin);
const registerPlugin =
// @ts-expect-error simple-mind-map 插件注册无类型声明
(MindMap as { usePlugin?: (p: unknown) => void }).usePlugin;
if (registerPlugin) {
registerPlugin(plugin);
}
}
});
const { nodeIconList, mergerIconList } = await loadIconModules();
await patchSvgRbox();
const hostEl = containerRef.current ?? document.body;
const instance = new MindMap({
el: containerRef.current,
el: hostEl,
data: initialDataRef.current,
theme: "classic",
layout: "logicalStructure",
@@ -262,6 +421,17 @@ const MindmapBlockView = ({
...(iconConfig as unknown[]),
]),
}) as MindMapInstance;
// 确保测量节点的缓存容器始终挂在有效 DOM 上,避免 appendChild 空指针
if (!(instance as any).commonCaches) {
(instance as any).commonCaches = {};
}
if (!(instance as any).commonCaches.measureRichtextNodeTextSizeEl) {
const measureDiv = document.createElement("div");
measureDiv.style.position = "fixed";
measureDiv.style.left = "-999999px";
((instance as any).commonCaches).measureRichtextNodeTextSizeEl = measureDiv;
(hostEl ?? document.body).appendChild(measureDiv);
}
instance.setMode?.("edit");
if (typeof window !== "undefined") {
@@ -284,15 +454,15 @@ const MindmapBlockView = ({
// 确保点击即可激活(调用节点自身的 active 逻辑,保持和官方一致)
// @ts-expect-error 第三方库节点对象
if (typeof node?.active === "function") {
// @ts-expect-error 第三方库节点对象
// @ts-expect-error simple-mind-map 节点对象缺少类型
node.active();
} else {
// 兜底:手动维护激活列表
// @ts-expect-error 第三方库缺少类型定义
// @ts-expect-error simple-mind-map 渲染器缺少类型
instance.renderer?.clearActiveNodeList?.();
// @ts-expect-error
// @ts-expect-error simple-mind-map 渲染器缺少类型
instance.renderer?.addNodeToActiveList?.(node, true);
// @ts-expect-error
// @ts-expect-error simple-mind-map 渲染器缺少类型
instance.renderer?.emitNodeActiveEvent?.(node);
}
const list = instance.renderer?.activeNodeList ?? [];
@@ -374,9 +544,7 @@ const MindmapBlockView = ({
// 图片预览(双击节点图片)
const [showImageViewer, setShowImageViewer] = useState(false);
const [viewerSrc, setViewerSrc] = useState("");
const [viewerNode, setViewerNode] = useState<any>(null);
const [viewerMeta, setViewerMeta] = useState<{ title?: string; width?: number; height?: number }>({});
const [hasActiveImg, setHasActiveImg] = useState(false);
const imgToolbarRef = useRef<HTMLDivElement | null>(null);
const [imgToolbarState, setImgToolbarState] = useState({
show: false,
@@ -387,7 +555,7 @@ const MindmapBlockView = ({
const imgToolbarHover = useRef(false);
const handleImage = () => {
const first = mindmap?.renderer?.activeNodeList?.[0] as any;
const first = mindmap?.renderer?.activeNodeList?.[0] as MindMapNode | undefined;
if (first?.getStyle) {
const placement = first.getStyle("imgPlacement", false) as string;
if (placement) setImagePosition(placement);
@@ -398,7 +566,7 @@ const MindmapBlockView = ({
// 预览:监听 mindmap 事件
useEffect(() => {
if (!mindmap) return;
const handler = (node: any, e: any) => {
const handler = (node: MindMapNode, e?: Event) => {
e?.stopPropagation?.();
e?.preventDefault?.();
const src =
@@ -407,7 +575,6 @@ const MindmapBlockView = ({
node?.data?.image;
if (src) {
setViewerSrc(src);
setViewerNode(node);
const size = node?.getData?.("imageSize") || {};
const title = node?.getData?.("imageTitle") || "";
setViewerMeta({
@@ -419,14 +586,14 @@ const MindmapBlockView = ({
}
};
const onActive = () => {
const list = mindmap.renderer?.activeNodeList || [];
const has = list.some((n: any) => !!n?.getData?.("image"));
setHasActiveImg(has);
const list = (mindmap.renderer?.activeNodeList || []) as MindMapNode[];
const has = list.some((n) => !!n?.getData?.("image"));
if (!has) setImgToolbarState((s) => ({ ...s, show: false }));
};
const showToolbarOnClick = (node: any, svgImg: any, evt: any) => {
const bbox = evt?.target?.getBoundingClientRect?.();
const placement = (node?.getStyle?.("imgPlacement") || "top") as any;
const showToolbarOnClick = (node: MindMapNode, _svgImg: unknown, evt: Event | undefined) => {
const target = evt?.target as Element | undefined;
const bbox = target?.getBoundingClientRect?.();
const placement = (node?.getStyle?.("imgPlacement") || "top") as "top" | "bottom" | "left" | "right";
if (!bbox) return;
setImagePosition(placement);
setImgToolbarState({
@@ -464,7 +631,11 @@ const MindmapBlockView = ({
);
setImgToolbarState((s) => ({ ...s, placement: p }));
};
const btn = (p: typeof placement, Icon: any, title: string) => (
const btn = (
p: typeof placement,
Icon: React.ComponentType<{ className?: string }>,
title: string,
) => (
<button
key={p}
type="button"
@@ -571,25 +742,65 @@ const MindmapBlockView = ({
window.alert("AI 能力占位:后续接入大模型生成/优化节点内容。");
};
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const reset = () => {
event.target.value = "";
};
const ext = (file.name.split(".").pop() || "").toLowerCase();
try {
const data = JSON.parse(String(reader.result));
// JSON / smm
if (ext === "json" || ext === "smm") {
const text = await file.text();
const data = JSON.parse(text);
mindmap?.setData(data);
mindmap?.command.clearHistory();
debouncedPersist(data);
return;
}
// XMind
if (ext === "xmind") {
const xmindParser = await import("simple-mind-map/src/parse/xmind.js");
const blob = new Blob([await file.arrayBuffer()]);
const data = await xmindParser.default.parseXmindFile(blob, (content: unknown[]) => {
const list = Array.isArray(content) ? content : [];
if (list.length > 1) {
window.alert("检测到 XMind 多画布,自动导入第一个画布。");
}
return list.length > 0 ? list[0] : content;
});
mindmap?.setData(data);
mindmap?.command.clearHistory();
debouncedPersist(data);
return;
}
// Markdown
if (ext === "md" || ext === "markdown") {
const { transformMarkdownTo } = await import(
"simple-mind-map/src/parse/markdownTo.js"
);
const text = await file.text();
const data = transformMarkdownTo(text) as MindMapData;
if (!data.data) {
data.data = { text: file.name.replace(/\.(md|markdown)$/i, "") || "中心主题" };
}
mindmap?.setData(data);
mindmap?.command.clearHistory();
debouncedPersist(data);
return;
}
window.alert("暂不支持该文件类型,请选择 .json / .smm / .xmind / .md");
} catch (error) {
console.error(error);
window.alert("导入失败:文件格式错误");
window.alert("导入失败:文件格式或内容错误");
} finally {
event.target.value = "";
reset();
}
};
reader.readAsText(file, "utf-8");
};
const handleNew = () => {
if (!window.confirm("确定要新建空白导图吗?当前未保存的修改将被覆盖。")) return;
@@ -618,21 +829,49 @@ const MindmapBlockView = ({
downloadJson(data, "mindmap");
};
const handleExportPng = async () => {
const handleExport = async (type: string, name = "mindmap") => {
try {
await mindmap?.doExport?.export("png", true, "mindmap");
await mindmap?.doExport?.export(type, true, name);
} catch (error) {
console.error(error);
window.alert("导出 PNG 失败,请稍后再试");
window.alert(`导出 ${type.toUpperCase()} 失败,请稍后再试`);
}
};
const handleExportPng = () => handleExport("png");
const handleExportSvg = () => handleExport("svg");
const handleExportPdf = () => handleExport("pdf");
const handleExportMd = () => handleExport("md");
const handleExportTxt = () => handleExport("txt");
const handleExportXmind = () => handleExport("xmind");
const handleSaveAs = () => handleExportJson();
const handleDeleteMindmap = useCallback(async () => {
if (!docId) {
editor.removeBlocks([block.id]);
return;
}
const confirmed = window.confirm("确认删除当前思维导图?此操作会移除文件并清空侧边栏记录。");
if (!confirmed) return;
const resp = await fetch(`/api/mindmap/${docId}`, { method: "DELETE" });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除思维导图失败");
return;
}
try {
window.localStorage.removeItem(autosaveKey);
} catch {
// ignore
}
emitAssetsChanged(docId);
editor.removeBlocks([block.id]);
}, [autosaveKey, block.id, docId, editor]);
const toolbarProps = {
canBack,
canForward,
activeCount,
painterMode,
onUndo: handleUndo,
onRedo: handleRedo,
@@ -655,8 +894,14 @@ const MindmapBlockView = ({
onNew: handleNew,
onOpenDirectory: handleOpenDirectory,
onSaveAs: handleSaveAs,
onDeleteMindmap: handleDeleteMindmap,
onExportJson: handleExportJson,
onExportPng: handleExportPng,
onExportSvg: handleExportSvg,
onExportPdf: handleExportPdf,
onExportMd: handleExportMd,
onExportTxt: handleExportTxt,
onExportXmind: handleExportXmind,
fileInputRef,
};
@@ -711,6 +956,7 @@ const MindmapBlockView = ({
<span className="truncate">{viewerMeta.title || "图片预览"}</span>
<span className="shrink-0">{viewerMeta.width && viewerMeta.height ? `${viewerMeta.width} × ${viewerMeta.height}px` : ""}</span>
</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={viewerSrc}
alt={viewerMeta.title || "预览"}
@@ -884,7 +1130,11 @@ const MindmapBlockView = ({
<MindmapToolbar {...toolbarProps} />
</div>
<div className="relative h-full w-full">
<div ref={containerRef} className="h-full w-full" />
<div
ref={containerRef}
className="h-full w-full"
contentEditable={false}
/>
<MindmapSidebarTrigger
activeSidebar={activeSidebar}
@@ -923,7 +1173,11 @@ const MindmapBlockView = ({
</div>
</div>
<div className="relative h-[520px] w-full overflow-hidden bg-white">
<div ref={containerRef} className="h-full w-full" />
<div
ref={containerRef}
className="h-full w-full"
contentEditable={false}
/>
<MindmapSidebarTrigger
activeSidebar={activeSidebar}
@@ -960,6 +1214,7 @@ export const mindmapBlock = createReactBlockSpec(
{
type: "mindmap",
propSchema: {
docId: { default: "" },
data: { default: defaultMindmapData },
},
content: "none",
@@ -1,28 +1,14 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useMemo, useState } from "react";
import Image from "next/image";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Toggle } from "@/components/ui/toggle";
import {
Bold,
Italic,
Underline,
Type,
Strikethrough,
Palette,
X,
Network,
ListTree,
Sliders,
Calculator,
StickyNote,
Bot,
} from "lucide-react";
import { Bold, Italic, Underline, Type, Strikethrough, Palette, X, Network } from "lucide-react";
import {
fontFamilyList,
fontSizeList,
lineHeightList,
colorList,
borderDasharrayList,
borderRadiusList,
borderWidthList,
@@ -38,6 +24,7 @@ import {
} from "./mindmapOptions";
import iconConfig from "./mindmapIconConfig";
import imageConfig from "./mindmapImageConfig";
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
// 避免 SSR 阶段触发浏览器依赖,改为运行时动态引入
// @ts-expect-error 第三方库缺少类型定义
const loadIconModules = async () => {
@@ -60,19 +47,6 @@ type SidebarProps = {
onClose: () => void;
};
const predefinedIcons = [
{
type: 'priority',
name: '优先级',
list: ['1', '2', '3', '4', '5', '6', '7', '8', '9']
},
{
type: 'progress',
name: '进度',
list: ['start', 'quarter', 'half', '3quarter', 'done'] // Standard keys usually
}
];
const ColorInput = ({
value,
onChange,
@@ -139,8 +113,8 @@ const StylePanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
].forEach((prop) => {
newStyle[prop] = node.getStyle(prop, false);
});
// eslint-disable-next-line react-hooks/set-state-in-effect
setStyle(newStyle);
// 异步更新,避免同步 setState 抛 lint
Promise.resolve().then(() => setStyle(newStyle));
}
}, [activeNodes]);
@@ -551,7 +525,7 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
dangerouslySetInnerHTML={{ __html: item.icon }}
/>
) : (
<img src={item.icon} alt={item.name} className="h-6 w-6 object-contain" />
<Image src={item.icon} alt={item.name} width={24} height={24} className="h-6 w-6 object-contain" />
)
) : (
<span className="text-xs text-gray-700">{item.name}</span>
@@ -578,7 +552,7 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
onClick={() => setSticker(item)}
className="flex h-16 w-16 items-center justify-center rounded border border-gray-200 bg-white hover:border-blue-400 hover:shadow-sm"
>
<img src={item.url} alt={group.name} className="h-14 w-14 object-contain" />
<Image src={item.url} alt={group.name} width={56} height={56} className="h-14 w-14 object-contain" />
</button>
))}
</div>
@@ -623,8 +597,23 @@ const OutlinePanel = ({ mindmap }: { mindmap: any }) => {
const activate = (node: any) => {
const r = mindmap?.renderer;
if (!node || !r) return;
r.activeNodeList = [node];
r.lastActiveNodeList = [node];
// 仅通过已有方法触发激活,避免直接改 renderer 属性
if (typeof r.clearActiveNodeList === "function") {
// @ts-expect-error 第三方库缺少类型
r.clearActiveNodeList();
}
if (typeof r.addNodeToActiveList === "function") {
// @ts-expect-error 第三方库缺少类型
r.addNodeToActiveList(node, true);
} else {
// 兜底:仍保留最小副作用写入
try {
// @ts-expect-error 第三方库 renderer 缺类型
r?.setActiveNode?.(node);
} catch {
// 最后兜底:不再直接改引用,避免 lint 报错
}
}
mindmap.emit?.("node_active", node, [node]);
r.setRootNodeCenter?.();
};
@@ -660,45 +649,336 @@ const OutlinePanel = ({ mindmap }: { mindmap: any }) => {
};
const SettingsPanel = ({ mindmap }: { mindmap: any }) => {
const [freeDrag, setFreeDrag] = useState<boolean>(!!mindmap?.opt?.enableFreeDrag);
const [wheel, setWheel] = useState<string>(mindmap?.opt?.mousewheelAction || "zoom");
const [aiOn, setAiOn] = useState<boolean>(true);
const [config, setConfig] = useState({
openPerformance: !!mindmap?.opt?.openPerformance,
enableFreeDrag: !!mindmap?.opt?.enableFreeDrag,
mousewheelAction: mindmap?.opt?.mousewheelAction || "zoom",
mousewheelZoomActionReverse: !!mindmap?.opt?.mousewheelZoomActionReverse,
openRealtimeRenderOnNodeTextEdit: !!mindmap?.opt?.openRealtimeRenderOnNodeTextEdit,
alwaysShowExpandBtn: !!mindmap?.opt?.alwaysShowExpandBtn,
enableAutoEnterTextEditWhenKeydown: !!mindmap?.opt?.enableAutoEnterTextEditWhenKeydown,
createNewNodeBehavior: mindmap?.opt?.createNewNodeBehavior || "default",
imgTextMargin: mindmap?.opt?.imgTextMargin ?? 5,
textContentMargin: mindmap?.opt?.textContentMargin ?? 2,
});
const [aiOn, setAiOn] = useState<boolean>(mindmap?.opt?.enableAi ?? true);
const [watermark, setWatermark] = useState({
show: !!mindmap?.opt?.watermarkConfig?.text,
onlyExport: mindmap?.opt?.watermarkConfig?.onlyExport ?? false,
belowNode: mindmap?.opt?.watermarkConfig?.belowNode ?? false,
text: mindmap?.opt?.watermarkConfig?.text ?? "",
lineSpacing: mindmap?.opt?.watermarkConfig?.lineSpacing ?? 100,
textSpacing: mindmap?.opt?.watermarkConfig?.textSpacing ?? 100,
angle: mindmap?.opt?.watermarkConfig?.angle ?? 30,
textStyle: {
color: mindmap?.opt?.watermarkConfig?.textStyle?.color ?? "#999",
opacity: mindmap?.opt?.watermarkConfig?.textStyle?.opacity ?? 0.5,
fontSize: mindmap?.opt?.watermarkConfig?.textStyle?.fontSize ?? 14,
},
});
const updateFreeDrag = (val: boolean) => {
setFreeDrag(val);
if (mindmap) mindmap.opt.enableFreeDrag = val;
useEffect(() => {
const opt = mindmap?.opt || {};
Promise.resolve().then(() =>
setConfig({
openPerformance: !!opt.openPerformance,
enableFreeDrag: !!opt.enableFreeDrag,
mousewheelAction: opt.mousewheelAction || "zoom",
mousewheelZoomActionReverse: !!opt.mousewheelZoomActionReverse,
openRealtimeRenderOnNodeTextEdit: !!opt.openRealtimeRenderOnNodeTextEdit,
alwaysShowExpandBtn: !!opt.alwaysShowExpandBtn,
enableAutoEnterTextEditWhenKeydown: !!opt.enableAutoEnterTextEditWhenKeydown,
createNewNodeBehavior: opt.createNewNodeBehavior || "default",
imgTextMargin: opt.imgTextMargin ?? 5,
textContentMargin: opt.textContentMargin ?? 2,
}),
);
const wm = opt.watermarkConfig || {};
Promise.resolve().then(() =>
setWatermark({
show: !!wm.text,
onlyExport: wm.onlyExport ?? false,
belowNode: wm.belowNode ?? false,
text: wm.text ?? "",
lineSpacing: wm.lineSpacing ?? 100,
textSpacing: wm.textSpacing ?? 100,
angle: wm.angle ?? 30,
textStyle: {
color: wm.textStyle?.color ?? "#999",
opacity: wm.textStyle?.opacity ?? 0.5,
fontSize: wm.textStyle?.fontSize ?? 14,
},
}),
);
Promise.resolve().then(() => setAiOn(opt.enableAi ?? true));
}, [mindmap]);
const updateOpt = (key: string, value: any, needRender = false) => {
setConfig((prev) => ({ ...prev, [key]: value }));
mindmap?.updateConfig?.({ [key]: value });
if (needRender && mindmap?.reRender) {
mindmap.reRender();
}
};
const updateWheel = (val: string) => {
setWheel(val);
if (mindmap) mindmap.opt.mousewheelAction = val;
const applyWatermark = (next: any) => {
const { show, ...cfg } = next;
const finalCfg = show ? cfg : { ...cfg, text: "" };
mindmap?.watermark?.updateWatermark?.(finalCfg);
mindmap?.updateConfig?.({ watermarkConfig: finalCfg });
};
const updateWatermark = (patch: any) => {
setWatermark((prev) => {
const next = {
...prev,
...patch,
textStyle: { ...prev.textStyle, ...(patch.textStyle || {}) },
};
applyWatermark(next);
return next;
});
};
const updateAi = (val: boolean) => {
setAiOn(val);
mindmap?.updateConfig?.({ enableAi: val });
};
return (
<div className="space-y-4">
<div className="space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500"></Label>
<Toggle
size="sm"
pressed={watermark.show}
onPressedChange={(v) => updateWatermark({ show: v })}
>
{watermark.show ? "开" : "关"}
</Toggle>
</div>
{watermark.show && (
<div className="space-y-3 rounded-md border border-gray-100 p-3">
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500"></Label>
<Toggle
size="sm"
pressed={watermark.onlyExport}
onPressedChange={(v) => updateWatermark({ onlyExport: v })}
>
{watermark.onlyExport ? "开" : "关"}
</Toggle>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500"></Label>
<Toggle
size="sm"
pressed={watermark.belowNode}
onPressedChange={(v) => updateWatermark({ belowNode: v })}
>
{watermark.belowNode ? "开" : "关"}
</Toggle>
</div>
<div className="space-y-2">
<Label className="text-xs text-gray-500"></Label>
<Input
value={watermark.text}
placeholder="请输入水印文字"
onChange={(e) => updateWatermark({ text: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<ColorInput
label="颜色"
value={watermark.textStyle.color}
onChange={(val) => updateWatermark({ textStyle: { color: val } })}
/>
<div className="space-y-1">
<Label className="text-xs text-gray-500"> 0~1</Label>
<Input
type="number"
min={0}
max={1}
step={0.1}
value={watermark.textStyle.opacity}
onChange={(e) =>
updateWatermark({
textStyle: {
opacity: Math.min(1, Math.max(0, Number(e.target.value) || 0)),
},
})
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500"></Label>
<Input
type="number"
min={8}
max={50}
step={1}
value={watermark.textStyle.fontSize}
onChange={(e) =>
updateWatermark({
textStyle: { fontSize: Number(e.target.value) || 14 },
})
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500"></Label>
<Input
type="number"
min={0}
max={90}
step={5}
value={watermark.angle}
onChange={(e) => updateWatermark({ angle: Number(e.target.value) || 0 })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500"></Label>
<Input
type="number"
min={10}
step={10}
value={watermark.lineSpacing}
onChange={(e) => updateWatermark({ lineSpacing: Number(e.target.value) || 0 })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500"></Label>
<Input
type="number"
min={10}
step={10}
value={watermark.textSpacing}
onChange={(e) => updateWatermark({ textSpacing: Number(e.target.value) || 0 })}
/>
</div>
</div>
</div>
)}
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500"></Label>
<Toggle
size="sm"
pressed={config.openPerformance}
onPressedChange={(v) => updateOpt("openPerformance", v)}
>
{config.openPerformance ? "开" : "关"}
</Toggle>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500"></Label>
<Toggle size="sm" pressed={freeDrag} onPressedChange={updateFreeDrag}>
{freeDrag ? "开" : "关"}
<Toggle
size="sm"
pressed={config.enableFreeDrag}
onPressedChange={(v) => updateOpt("enableFreeDrag", v)}
>
{config.enableFreeDrag ? "开" : "关"}
</Toggle>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500"></Label>
<Toggle
size="sm"
pressed={config.openRealtimeRenderOnNodeTextEdit}
onPressedChange={(v) => updateOpt("openRealtimeRenderOnNodeTextEdit", v)}
>
{config.openRealtimeRenderOnNodeTextEdit ? "开" : "关"}
</Toggle>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500"></Label>
<Toggle
size="sm"
pressed={config.alwaysShowExpandBtn}
onPressedChange={(v) => updateOpt("alwaysShowExpandBtn", v, true)}
>
{config.alwaysShowExpandBtn ? "开" : "关"}
</Toggle>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500"></Label>
<Toggle
size="sm"
pressed={config.enableAutoEnterTextEditWhenKeydown}
onPressedChange={(v) => updateOpt("enableAutoEnterTextEditWhenKeydown", v)}
>
{config.enableAutoEnterTextEditWhenKeydown ? "开" : "关"}
</Toggle>
</div>
<div className="space-y-2">
<Label className="text-xs text-gray-500"></Label>
<NativeSelect
value={wheel}
onChange={updateWheel}
value={config.mousewheelAction}
onChange={(v) => updateOpt("mousewheelAction", v)}
options={[
{ label: "缩放", value: "zoom" },
{ label: "平移", value: "move" },
]}
/>
</div>
{config.mousewheelAction === "zoom" && (
<div className="space-y-2">
<Label className="text-xs text-gray-500"></Label>
<NativeSelect
value={String(config.mousewheelZoomActionReverse)}
onChange={(v) => updateOpt("mousewheelZoomActionReverse", v === "true")}
options={[
{ label: "常规", value: "false" },
{ label: "反转", value: "true" },
]}
/>
</div>
)}
<div className="space-y-2">
<Label className="text-xs text-gray-500"></Label>
<NativeSelect
value={config.createNewNodeBehavior}
onChange={(v) => updateOpt("createNewNodeBehavior", v)}
options={[
{ label: "默认", value: "default" },
{ label: "不激活", value: "notActive" },
{ label: "仅激活新节点", value: "activeOnly" },
]}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-gray-500"></Label>
<Input
type="number"
min={0}
step={1}
value={config.imgTextMargin}
onChange={(e) => updateOpt("imgTextMargin", Number(e.target.value) || 0, true)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500"></Label>
<Input
type="number"
min={0}
step={1}
value={config.textContentMargin}
onChange={(e) => updateOpt("textContentMargin", Number(e.target.value) || 0, true)}
/>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">AI </Label>
<Toggle size="sm" pressed={aiOn} onPressedChange={setAiOn}>
<Toggle size="sm" pressed={aiOn} onPressedChange={updateAi}>
{aiOn ? "开" : "关"}
</Toggle>
</div>
<p className="text-xs text-gray-400"></p>
</div>
);
};
@@ -739,11 +1019,13 @@ const NotePanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMa
// 当选中节点变化时,自动展示首个节点的备注
useEffect(() => {
if (!activeNodes?.length) {
setNote("");
Promise.resolve().then(() => setNote(""));
return;
}
const current = readNote(activeNodes[0]);
setNote(typeof current === "string" ? current : "");
Promise.resolve().then(() =>
setNote(typeof current === "string" ? current : ""),
);
}, [activeNodes]);
const getActiveList = () =>
@@ -794,28 +1076,404 @@ const NotePanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMa
);
};
const AiPanel = () => {
type AiMode = "chat" | "full" | "partial";
const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapNode[] }) => {
const [prompt, setPrompt] = useState("");
const send = () => window.alert("AI 对话占位,后续接入大模型:\n" + prompt);
const [mode, setMode] = useState<AiMode>("full");
const [model, setModel] = useState("qwen3:30b-a3b-instruct-2507-q4_K_M");
const [baseUrl, setBaseUrl] = useState("http://localhost:11434/api/chat");
const [systemPrompt, setSystemPrompt] = useState("请用 Markdown,仅使用标题和无序列表结构输出思维导图内容。不要添加额外解释。");
const [streamText, setStreamText] = useState("");
const [loading, setLoading] = useState(false);
const controllerRef = React.useRef<AbortController | null>(null);
const resetStream = () => {
setStreamText("");
};
const stop = () => {
controllerRef.current?.abort();
controllerRef.current = null;
setLoading(false);
};
const runChat = async () => {
if (!prompt.trim()) return;
resetStream();
setLoading(true);
const controller = new AbortController();
controllerRef.current = controller;
try {
const res = await fetch(baseUrl, {
method: "POST",
signal: controller.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
stream: true,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: prompt.trim() },
],
}),
});
const reader = res.body?.getReader();
if (!reader) throw new Error("无法读取流");
const decoder = new TextDecoder();
let full = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
// Ollama 返回 json 行
const lines = chunk.split("\n").filter(Boolean);
for (const line of lines) {
try {
const json = JSON.parse(line);
if (json.message?.content) {
full += json.message.content;
setStreamText((prev) => prev + json.message.content);
}
} catch {
// 忽略非 json 行
}
}
}
return full;
} finally {
setLoading(false);
controllerRef.current = null;
}
};
// 将 AI 文本解析为子节点数组,尽量兼容宽松 Markdown 列表
const parseChildrenFromText = async (text: string) => {
const { transformMarkdownTo } = await import("simple-mind-map/src/parse/markdownTo.js");
const { createUid } = await import("simple-mind-map/src/utils/index.js");
const safeText = text || "";
let children = transformMarkdownTo(safeText)?.children || [];
if (!children.length) {
const lines = safeText
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
const listLines = lines.filter((l) => /^[-*+]\s+/.test(l)).map((l) => l.replace(/^[-*+]\s+/, ""));
const headingLines = lines.filter((l) => /^#+\s+/.test(l)).map((l) => l.replace(/^#+\s+/, ""));
const useLines = listLines.length ? listLines : headingLines.length ? headingLines : lines;
children = useLines.map((t) => ({ data: { text: t } }));
}
if (!children.length) {
children = [{ data: { text: safeText.slice(0, 200) || "新节点" } }];
}
const fill = (nodes: any[]) => {
nodes.forEach((node) => {
if (!node.data) node.data = {};
if (!node.data.text) node.data.text = "新节点";
if (!node.data.uid) node.data.uid = createUid();
if (node.children?.length) fill(node.children);
});
};
fill(children);
return JSON.parse(JSON.stringify(children));
};
const getActiveList = () =>
(activeNodes && activeNodes.length ? activeNodes : mindmap?.renderer?.activeNodeList || []) as any[];
const updateDataByUids = async (uids: string[], updater: (node: any) => void) => {
const source = mindmap?.getData?.(true) || mindmap?.getData?.();
const dataClone = source ? JSON.parse(JSON.stringify(source)) : null;
if (!dataClone) {
window.alert("无法获取导图数据,操作失败。");
return false;
}
// 确保整棵树都有 uid,避免官方逻辑依赖 uid 时抛错
const { createUid } = await import("simple-mind-map/src/utils/index.js");
const sanitizeNode = (node: any) => {
if (!node || typeof node !== "object") return null;
if (!node.data) node.data = {};
if (!node.data.uid) node.data.uid = createUid();
if (typeof node.data.text !== "string") {
node.data.text =
node.data.text != null ? String(node.data.text) : "新节点";
}
if (Array.isArray(node.children)) {
node.children = node.children
.map((c: any) => sanitizeNode(c))
.filter(Boolean);
} else {
node.children = [];
}
return node;
};
sanitizeNode(dataClone);
const cleanRenderCallbacks = () => {
try {
const r = mindmap?.renderer;
if (r && Array.isArray((r as any).renderCallbackList)) {
// @ts-expect-error 第三方库内部字段
r.renderCallbackList = (r.renderCallbackList as any[]).filter(
(fn) => typeof fn === "function",
);
}
} catch {
// ignore
}
};
cleanRenderCallbacks();
const walk = (node: any) => {
if (uids.includes(node?.data?.uid)) {
updater(node);
}
if (node.children?.length) {
node.children.forEach(walk);
}
};
walk(dataClone);
cleanRenderCallbacks();
mindmap?.updateData?.(dataClone);
cleanRenderCallbacks();
return true;
};
const applyFullMindmap = async (content: string) => {
if (!content.trim()) return;
try {
const { transformMarkdownTo } = await import("simple-mind-map/src/parse/markdownTo.js");
const data = transformMarkdownTo(content);
if (!data?.data) {
data.data = { text: "中心主题" };
}
mindmap?.setData?.(data);
mindmap?.command?.clearHistory?.();
} catch (error) {
console.error(error);
window.alert("解析 AI 结果失败,请检查格式(需 Markdown 标题和无序列表)。");
}
};
const applyPartialMindmap = async (text: string, presetChildren?: any[]) => {
const list = getActiveList();
if (!list?.length) {
window.alert("请选择节点后再续写。");
return;
}
try {
const children = presetChildren || (await parseChildrenFromText(text));
const uids = list
.map(
(node: any) =>
node?.nodeData?.data?.uid ||
node?.nodeData?.uid ||
node?.getData?.("uid") ||
node?.uid,
)
.filter(Boolean);
if (!uids.length) {
window.alert("未获取到选中节点的 UID,续写失败。");
return;
}
const ok = await updateDataByUids(uids as string[], (node) => {
node.children = Array.isArray(node.children) ? node.children : [];
const childrenCopy = JSON.parse(JSON.stringify(children));
node.children.push(...childrenCopy);
});
if (!ok) return;
mindmap?.renderer?.render?.(mindmap?.getData?.());
} catch (e) {
console.error("AI Markdown Parsing Error:", e);
if (e instanceof Error) {
console.error("Error Message:", e.message);
console.error("Error Stack:", e.stack);
}
window.alert("解析 AI 返回的 Markdown 失败,请检查格式。");
}
};
const handleSend = async () => {
const text = (await runChat()) || streamText;
if (mode === "full" && text) {
await applyFullMindmap(text);
}
if (mode === "partial" && text) {
await applyPartialMindmap(text);
}
};
const handleSaveToNote = () => {
const text = streamText.trim();
if (!text) {
window.alert("暂无可保存的内容");
return;
}
const list = getActiveList();
if (!list?.length) {
window.alert("请选择节点后再保存备注");
return;
}
list.forEach((node: any) => mindmap?.execCommand?.("SET_NODE_NOTE", node, text));
};
const handleAppendSingleChild = async () => {
const text = streamText.trim();
if (!text) {
window.alert("暂无可生成的内容");
return;
}
const children = await parseChildrenFromText(text.slice(0, 500));
const first = children.length ? [children[0]] : [{ data: { text } }];
await applyPartialMindmap("", first);
};
const handleAppendMultiChild = async () => {
const text = streamText.trim();
if (!text) {
window.alert("暂无可生成的内容");
return;
}
const children = await parseChildrenFromText(text);
await applyPartialMindmap("", children);
};
const handleReplaceCurrent = async () => {
const text = streamText.trim();
if (!text) {
window.alert("暂无可替换的内容");
return;
}
const list = getActiveList();
if (!list?.length) {
window.alert("请选择节点后再修改");
return;
}
const uids = list
.map(
(node: any) =>
node?.nodeData?.data?.uid ||
node?.nodeData?.uid ||
node?.getData?.("uid") ||
node?.uid,
)
.filter(Boolean) as string[];
const ok = await updateDataByUids(uids, (node) => {
if (!node.data) node.data = {};
node.data.text = text;
});
if (ok) {
mindmap?.renderer?.render?.(mindmap?.getData?.());
}
};
return (
<div className="space-y-3">
<Label className="text-xs text-gray-500">AI </Label>
<Label className="text-xs text-gray-500"></Label>
<NativeSelect
value={mode}
onChange={(v) => setMode(v as AiMode)}
options={[
{ label: "整图生成", value: "full" },
{ label: "对话", value: "chat" },
{ label: "选中节点续写", value: "partial" },
]}
/>
<div className="space-y-2">
<Label className="text-xs text-gray-500"></Label>
<Input value={model} onChange={(e) => setModel(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-xs text-gray-500"></Label>
<Input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-xs text-gray-500"></Label>
<Input value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-xs text-gray-500">{mode === "full" ? "生成主题" : mode === "partial" ? "续写内容" : "对话输入"}</Label>
<textarea
className="w-full rounded-md border border-gray-200 p-2 text-sm"
rows={4}
rows={5}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="输入需求,后续将调用 AI 生成/优化节点"
placeholder={
mode === "full"
? "例如:帮我生成一份 OKR 思维导图"
: mode === "partial"
? "描述要续写的方向或要补充的要点"
: "输入你想询问的内容"
}
/>
<button className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600" onClick={send}>
</div>
<div className="flex gap-2">
<button
disabled={loading}
className="flex-1 rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
onClick={handleSend}
>
{loading ? "生成中..." : "发送"}
</button>
<button
disabled={!loading}
className="w-20 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
onClick={stop}
>
</button>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500"></Label>
<div className="h-40 overflow-auto rounded-md border border-gray-200 p-2 text-sm whitespace-pre-wrap">
{streamText || "等待输出..."}
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500"></Label>
<div className="grid grid-cols-2 gap-2">
<button
className="rounded-md border border-gray-300 px-2 py-2 text-sm hover:bg-gray-50"
onClick={handleSaveToNote}
>
</button>
<button
className="rounded-md border border-gray-300 px-2 py-2 text-sm hover:bg-gray-50"
onClick={handleAppendSingleChild}
>
</button>
<button
className="rounded-md border border-gray-300 px-2 py-2 text-sm hover:bg-gray-50"
onClick={handleAppendMultiChild}
>
</button>
<button
className="rounded-md border border-gray-300 px-2 py-2 text-sm hover:bg-gray-50"
onClick={handleReplaceCurrent}
>
</button>
</div>
</div>
{mode === "full" && (
<p className="text-xs text-gray-400">
使 AI Markdown Ollama
</p>
)}
{mode === "partial" && (
<p className="text-xs text-gray-400">
AI Markdown
</p>
)}
</div>
);
};
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
export const MindmapSidebar = ({ mindmap, activeNodes, activeTab, onClose }: SidebarProps) => {
const content = useMemo(() => {
switch (activeTab as SidebarPanel | null) {
@@ -838,7 +1496,7 @@ export const MindmapSidebar = ({ mindmap, activeNodes, activeTab, onClose }: Sid
case "note":
return <NotePanel mindmap={mindmap} activeNodes={activeNodes} />;
case "ai":
return <AiPanel />;
return <AiPanel mindmap={mindmap} activeNodes={activeNodes} />;
default:
return null;
}
@@ -11,7 +11,6 @@ import {
type ToolbarProps = {
canBack: boolean;
canForward: boolean;
activeCount: number;
painterMode: boolean;
onUndo: () => void;
onRedo: () => void;
@@ -35,8 +34,14 @@ type ToolbarProps = {
onNew: () => void;
onOpenDirectory: () => void;
onSaveAs: () => void;
onDeleteMindmap: () => void;
onExportJson: () => void;
onExportPng: () => void;
onExportSvg: () => void;
onExportPdf: () => void;
onExportMd: () => void;
onExportTxt: () => void;
onExportXmind: () => void;
fileInputRef: React.RefObject<HTMLInputElement>;
};
@@ -77,12 +82,9 @@ const ToolbarButton = ({
</button>
);
const Divider = () => <div className="mx-1 h-8 w-px bg-gray-200" />;
export const MindmapToolbar = ({
canBack,
canForward,
activeCount,
painterMode,
onUndo,
onRedo,
@@ -106,10 +108,18 @@ export const MindmapToolbar = ({
onNew,
onOpenDirectory,
onSaveAs,
onDeleteMindmap,
onExportJson,
onExportPng,
onExportSvg,
onExportPdf,
onExportMd,
onExportTxt,
onExportXmind,
fileInputRef,
}: ToolbarProps) => {
const [showExport, setShowExport] = React.useState(false);
const nodeHandlers: Record<NodeToolbarKey, () => void> = {
back: onUndo,
forward: onRedo,
@@ -137,8 +147,8 @@ export const MindmapToolbar = ({
openFile: () => fileInputRef.current?.click(),
import: () => fileInputRef.current?.click(),
saveAs: onSaveAs,
exportJson: onExportJson,
exportPng: onExportPng,
deleteFile: onDeleteMindmap,
exportMenu: () => setShowExport((v) => !v),
};
const getNodeDisabled = (key: NodeToolbarKey) => {
@@ -184,10 +194,36 @@ export const MindmapToolbar = ({
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
accept=".json,.smm,.xmind,.md,.markdown,application/json"
className="hidden"
onChange={onImport}
/>
{showExport ? (
<div className="absolute right-2 top-full z-50 mt-2 w-40 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
{[
{ label: "JSON", onClick: onExportJson },
{ label: "PNG", onClick: onExportPng },
{ label: "SVG", onClick: onExportSvg },
{ label: "PDF", onClick: onExportPdf },
{ label: "Markdown", onClick: onExportMd },
{ label: "TXT", onClick: onExportTxt },
{ label: "XMind", onClick: onExportXmind },
].map((item) => (
<button
key={item.label}
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
onClick={() => {
setShowExport(false);
item.onClick();
}}
>
<span>{item.label}</span>
<i className="iconfont iconexport text-[12px]" />
</button>
))}
</div>
) : null}
</div>
</div>
);
@@ -24,8 +24,8 @@ export type FileToolbarKey =
| "openFile"
| "import"
| "saveAs"
| "exportJson"
| "exportPng";
| "deleteFile"
| "exportMenu";
type ToolbarMeta = {
label: string;
@@ -85,8 +85,8 @@ export const fileToolbarOrder: FileToolbarKey[] = [
"openFile",
"import",
"saveAs",
"exportJson",
"exportPng",
"deleteFile",
"exportMenu",
];
export const fileToolbarMeta: FileToolbarConfig = {
@@ -95,6 +95,6 @@ export const fileToolbarMeta: FileToolbarConfig = {
openFile: { label: "打开", iconClass: "icondakai" },
import: { label: "导入", iconClass: "icondaoru" },
saveAs: { label: "另存为", iconClass: "iconlingcunwei" },
exportJson: { label: "导出 JSON", iconClass: "iconexport" },
exportPng: { label: "导出 PNG", iconClass: "iconPNG" },
deleteFile: { label: "删除", iconClass: "iconshanchu" },
exportMenu: { label: "导出", iconClass: "iconexport" },
};
@@ -15,6 +15,7 @@ import {
import { useRouter } from "next/navigation";
import type { CustomBlockSchema } from "../schema";
import { deleteOnlineTable } from "@/lib/online-table";
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
type InlineNode = { text?: unknown };
type TableMenuBlock = Parameters<
@@ -62,13 +63,16 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId: pageId }),
});
if (typeof window !== "undefined") {
emitDocumentsChanged(pageId);
}
}
}
editor.removeBlocks([block.id]);
router.refresh();
}, [block, editor, router]);
const handleDeleteBlock = useCallback(() => {
const handleDeleteBlock = useCallback(async () => {
if (block.type === "pageReference") {
void removePageReference();
return;
@@ -80,11 +84,43 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
.catch((error) => console.error("删除在线表格失败", error));
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } }));
}
emitAssetsChanged(currentDocumentId);
}
}
editor.removeBlocks([block.id]);
}, [block, editor, removePageReference]);
return;
}
if (block.type === "media") {
const assetId = block.props.assetId as string | undefined;
if (assetId) {
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds: [assetId] }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除附件失败");
return;
}
emitAssetsChanged(currentDocumentId);
}
editor.removeBlocks([block.id]);
return;
}
if (block.type === "mindmap") {
const resp = await fetch(`/api/mindmap/${currentDocumentId}`, { method: "DELETE" });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除思维导图失败");
return;
}
emitAssetsChanged(currentDocumentId);
editor.removeBlocks([block.id]);
return;
}
editor.removeBlocks([block.id]);
}, [block, currentDocumentId, editor, removePageReference]);
const turnToPage = useCallback(async () => {
const response = await fetch("/api/documents/create-child", {
@@ -175,6 +175,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
[
{
type: "mindmap",
props: { docId: currentDocumentId },
},
],
referenceBlock,
@@ -14,13 +14,10 @@ import { mediaBlock } from "./blocks/MediaBlock";
import { onlineTableBlock } from "./blocks/OnlineTableBlock";
import { mindmapBlock } from "./blocks/MindmapBlock";
const headingSpec =
typeof window === "undefined"
? defaultBlockSpecs.heading
: createHeadingBlockSpec({
const headingSpec = createHeadingBlockSpec({
levels: [1, 2, 3, 4, 5],
allowToggleHeadings: true,
});
});
export const customBlockSchema = BlockNoteSchema.create({
blockSpecs: {
@@ -31,7 +28,7 @@ export const customBlockSchema = BlockNoteSchema.create({
progressMeter: progressBlock,
media: mediaBlock,
onlineTable: onlineTableBlock(),
mindmap: mindmapBlock,
mindmap: mindmapBlock(),
},
inlineContentSpecs: defaultInlineContentSpecs,
styleSpecs: defaultStyleSpecs,
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { MediaAsset, MediaKind, MediaSelection } from "@/types/media";
import { emitAssetsChanged } from "@/lib/events";
export type PickerTab = "upload" | "recent" | "link" | "icon";
@@ -165,6 +166,7 @@ export function ImagePickerDialog({
if (!payload.asset?.file_url) {
throw new Error("返回数据缺少文件地址");
}
emitAssetsChanged(documentId, payload.asset);
onSelect({
assetId: payload.asset.id,
fileUrl: payload.asset.file_url,
@@ -224,6 +226,7 @@ export function ImagePickerDialog({
if (!payload.asset?.file_url) {
throw new Error("缺少图片地址");
}
emitAssetsChanged(documentId, payload.asset);
onSelect({
assetId: payload.asset.id,
fileUrl: payload.asset.file_url,
@@ -22,6 +22,7 @@ interface FileTreeProps {
disableSelection?: boolean;
onDropFiles?: (docId: string, files: FileList) => void;
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
onAssetClick?: (asset: MediaAsset, event: React.MouseEvent) => void;
}
const INDENT = 16;
@@ -42,6 +43,7 @@ export function FileTree({
disableSelection = false,
onDropFiles,
onAssetContextMenu,
onAssetClick,
}: FileTreeProps) {
if (nodes.length === 0) {
return <div className="px-4 py-6 text-sm text-gray-400"></div>;
@@ -83,6 +85,7 @@ export function FileTree({
disableSelection={disableSelection}
onDropFiles={onDropFiles}
onAssetContextMenu={onAssetContextMenu}
onAssetClick={onAssetClick}
/>
))}
</div>
@@ -106,6 +109,7 @@ interface FileTreeNodeProps {
disableSelection: boolean;
onDropFiles?: (docId: string, files: FileList) => void;
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
onAssetClick?: (asset: MediaAsset, event: React.MouseEvent) => void;
}
function FileTreeNode({
@@ -125,6 +129,7 @@ function FileTreeNode({
disableSelection,
onDropFiles,
onAssetContextMenu,
onAssetClick,
}: FileTreeNodeProps) {
const isExpanded = expanded.has(node.id);
const assets = useMemo(() => assetsByDoc[node.id] ?? [], [assetsByDoc, node.id]);
@@ -202,13 +207,23 @@ function FileTreeNode({
onToggleAssetSelect ? () => onToggleAssetSelect(asset.id) : undefined
}
onSelectOnly={onSelectOnlyAsset ? () => onSelectOnlyAsset(asset.id) : undefined}
onClick={() => onOpenAsset(asset)}
onClick={(e) => {
if (onAssetClick) {
onAssetClick(asset, e);
} else {
onOpenAsset(asset);
}
}}
onDoubleClick={() => onOpenAsset(asset)}
stopBubble
onContextMenu={
onAssetContextMenu
? (e) => {
e.preventDefault();
e.stopPropagation();
if (onAssetClick) {
onAssetClick(asset, e);
}
onAssetContextMenu(e, asset);
}
: undefined
@@ -264,17 +279,23 @@ function FileLeafRow({
onSelectOnly?: () => void;
stopBubble?: boolean;
onContextMenu?: (event: React.MouseEvent) => void;
onDoubleClick?: () => void;
}) {
return (
<div
className="flex items-center gap-2 rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f8fafc]"
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f8fafc]",
selected && "bg-[#e8f2ff] text-[#2563eb]",
)}
style={{ paddingLeft: depth * INDENT + 32 }}
onClick={(e) => {
if (stopBubble) e.stopPropagation();
// 附件行默认只负责选中,不直接打开,避免误触下载
if (selectable) return;
onClick();
}}
onDoubleClick={(e) => {
if (stopBubble) e.stopPropagation();
onDoubleClick?.();
}}
onMouseDown={(e) => {
if (stopBubble) e.stopPropagation();
}}
@@ -44,6 +44,8 @@ import { supabaseBrowser } from "@/lib/supabase/client";
import { useSearchPaletteStore } from "@/store/search-palette";
import { FileTree } from "@/components/sidebar/file-tree";
import type { MediaAsset } from "@/types/media";
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
const TOP_BUTTONS = [
{ id: "search", icon: SearchIcon, label: "搜索" },
@@ -93,6 +95,13 @@ export function Sidebar({ initialData }: SidebarProps) {
const [trashOpen, setTrashOpen] = useState(false);
const [trashSearch, setTrashSearch] = useState("");
const [emptyingTrash, setEmptyingTrash] = useState(false);
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>(sidebarData.mediaAssets ?? []);
const [mindmapDocs, setMindmapDocs] = useState<string[]>(sidebarData.mindmapDocs ?? []);
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
null,
);
const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(new Set());
const [lastSelectedAssetId, setLastSelectedAssetId] = useState<string | null>(null);
const workspaceMenuRef = useRef<HTMLDivElement>(null);
@@ -104,10 +113,43 @@ export function Sidebar({ initialData }: SidebarProps) {
});
}, [sidebarData.documents]);
useEffect(() => {
setMediaAssets(sidebarData.mediaAssets ?? []);
}, [sidebarData.mediaAssets]);
useEffect(() => {
setMindmapDocs(sidebarData.mindmapDocs ?? []);
}, [sidebarData.mindmapDocs]);
useEffect(() => {
setSelectedAssetIds(new Set());
setLastSelectedAssetId(null);
}, [mediaAssets, sidebarData.documents]);
useEffect(() => {
setOpen(false);
}, [activeId, setOpen]);
useEffect(() => {
const handler = (event: Event) => {
const custom = event as CustomEvent<{ docId?: string; asset?: MediaAsset }>;
const asset = custom.detail?.asset as MediaAsset | undefined;
if (asset?.id) {
setMediaAssets((prev) => {
if (prev.find((item) => item.id === asset.id)) return prev;
return [asset, ...prev];
});
}
void sidebarQuery.refetch();
};
window.addEventListener(ASSETS_CHANGED_EVENT, handler);
window.addEventListener(DOCUMENTS_CHANGED_EVENT, handler);
return () => {
window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
window.removeEventListener(DOCUMENTS_CHANGED_EVENT, handler);
};
}, [sidebarQuery]);
const refreshTree = useCallback(async () => {
await sidebarQuery.refetch();
}, [sidebarQuery]);
@@ -128,6 +170,27 @@ export function Sidebar({ initialData }: SidebarProps) {
};
}, [refreshTree]);
useEffect(() => {
const channel = supabaseBrowser
.channel("media-assets-feed")
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "media_assets",
filter: `workspace_id=eq.${sidebarData.activeWorkspaceId}`,
},
() => {
void sidebarQuery.refetch();
},
)
.subscribe();
return () => {
supabaseBrowser.removeChannel(channel);
};
}, [sidebarData.activeWorkspaceId, sidebarQuery]);
const sections = useMemo(() => buildSidebarSectionsFromTree(tree), [tree]);
const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]);
const publicNodes = useMemo(() => sections.find((section) => section.id === "public")?.nodes ?? [], [sections]);
@@ -154,15 +217,67 @@ export function Sidebar({ initialData }: SidebarProps) {
const assetsByDoc = useMemo(() => {
const map: Record<string, MediaAsset[]> = {};
const assets = sidebarData.mediaAssets ?? [];
const assets = mediaAssets;
assets.forEach((asset) => {
if (!map[asset.document_id]) {
map[asset.document_id] = [];
}
map[asset.document_id].push(asset);
});
if (mindmapDocs.length > 0) {
mindmapDocs.forEach((docId) => {
const doc = sidebarData.documents.find((d) => d.id === docId);
const workspaceId = doc?.workspace_id ?? "";
if (!map[docId]) {
map[docId] = [];
}
// 避免重复插入
const alreadyExists = map[docId].some(
(asset) => asset.asset_type === "mindmap",
);
if (!alreadyExists) {
map[docId].push({
id: `mindmap-${docId}`,
workspace_id: workspaceId,
document_id: docId,
asset_type: "mindmap",
file_url: `/documents/${docId}`,
thumbnail_url: null,
bucket: null,
storage_path: null,
file_name: "mindmap.json",
file_size: null,
mime_type: "application/json",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: "",
updated_at: "",
});
}
});
}
return map;
}, [sidebarData.mediaAssets]);
}, [sidebarData.documents, mediaAssets, mindmapDocs]);
const assetOrder = useMemo(() => {
const list: string[] = [];
Object.values(assetsByDoc).forEach((arr) => {
arr.forEach((asset) => list.push(asset.id));
});
return list;
}, [assetsByDoc]);
const assetIndexMap = useMemo(() => {
const map = new Map<string, number>();
assetOrder.forEach((id, idx) => map.set(id, idx));
return map;
}, [assetOrder]);
const activeWorkspace =
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
@@ -170,6 +285,8 @@ export function Sidebar({ initialData }: SidebarProps) {
const handleOpenDocument = useCallback(
(documentId: string, mode: "main" | "sidebar") => {
setSelectedAssetIds(new Set());
setLastSelectedAssetId(null);
const targetPath = `/documents/${documentId}`;
if (mode === "main") {
router.push(targetPath);
@@ -248,6 +365,11 @@ export function Sidebar({ initialData }: SidebarProps) {
}, []);
const handleOpenAsset = useCallback((asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
router.push(`/documents/${asset.document_id}`);
setOpen(false);
return;
}
const url = asset.signed_url ?? asset.file_url;
if (!url) {
window.alert("暂无可用的文件链接");
@@ -256,8 +378,213 @@ export function Sidebar({ initialData }: SidebarProps) {
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
}, [router, setOpen]);
const handleAssetContextMenu = useCallback(
(event: React.MouseEvent, asset: MediaAsset) => {
event.preventDefault();
if (!selectedAssetIds.has(asset.id)) {
setSelectedAssetIds(new Set([asset.id]));
setLastSelectedAssetId(asset.id);
}
setAssetMenu({ asset, x: event.clientX, y: event.clientY });
},
[selectedAssetIds],
);
const handleSelectAsset = useCallback(
(asset: MediaAsset, event?: { type?: string; shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean }) => {
const evtType = event?.type ?? "";
if (evtType === "contextmenu" && selectedAssetIds.has(asset.id)) {
setLastSelectedAssetId(asset.id);
return;
}
setSelectedAssetIds((prev) => {
let next = new Set(prev);
const withShift = Boolean(event?.shiftKey) && lastSelectedAssetId && assetIndexMap.has(lastSelectedAssetId);
if (withShift) {
const start = assetIndexMap.get(lastSelectedAssetId!) ?? 0;
const end = assetIndexMap.get(asset.id) ?? start;
const [lo, hi] = start < end ? [start, end] : [end, start];
const idsInRange = assetOrder.slice(lo, hi + 1);
next = new Set([...prev, ...idsInRange]);
} else if (event?.metaKey || event?.ctrlKey) {
if (next.has(asset.id)) {
next.delete(asset.id);
} else {
next.add(asset.id);
}
} else {
next = new Set([asset.id]);
}
return next;
});
setLastSelectedAssetId(asset.id);
},
[assetIndexMap, assetOrder, lastSelectedAssetId, selectedAssetIds],
);
const toggleAssetCheckbox = useCallback((assetId: string) => {
setSelectedAssetIds((prev) => {
const next = new Set(prev);
if (next.has(assetId)) {
next.delete(assetId);
} else {
next.add(assetId);
}
return next;
});
setLastSelectedAssetId(assetId);
}, []);
const selectOnlyAsset = useCallback((assetId: string) => {
setSelectedAssetIds(new Set([assetId]));
setLastSelectedAssetId(assetId);
}, []);
const handleCopyAssetLink = useCallback(
async (asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
await copyText(buildDocumentUrl(asset.document_id), "页面链接已复制");
return;
}
const url = asset.signed_url ?? asset.file_url ?? "";
if (!url) {
window.alert("暂无可用的文件链接");
return;
}
await copyText(url, "附件链接已复制");
},
[],
);
const handleCopyAssetPath = useCallback(async (asset: MediaAsset) => {
const path =
asset.asset_type === "mindmap"
? `documents/${asset.document_id}/mindmap.json`
: asset.storage_path || asset.file_url || asset.file_name || "附件";
await copyText(path, "存储路径已复制");
}, []);
const handleDownloadAsset = useCallback(async (asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
const resp = await fetch(`/api/mindmap/${asset.document_id}`);
if (!resp.ok) {
window.alert("下载失败");
return;
}
const payload = await resp.json().catch(() => null);
const data = payload?.data ?? {};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "mindmap.json";
a.click();
URL.revokeObjectURL(url);
return;
}
const url = asset.signed_url ?? asset.file_url;
if (!url) {
window.alert("暂无可用的下载链接");
return;
}
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
}, []);
const handleRenameAsset = useCallback(
async (asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
window.alert("思维导图暂不支持重命名");
return;
}
const input = window.prompt("输入新文件名", asset.file_name ?? "");
if (!input || !input.trim()) return;
const newName = input.trim();
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "rename", assetIds: [asset.id], newName }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "重命名失败");
return;
}
await sidebarQuery.refetch();
setAssetMenu(null);
emitAssetsChanged(asset.document_id);
},
[sidebarQuery],
);
const handleMoveAsset = useCallback(
async (asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
window.alert("思维导图文件无需移动,请在页面中直接编辑");
return;
}
const target = window.prompt("输入目标页面 ID", asset.document_id);
if (!target || !target.trim()) return;
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "move",
assetIds: [asset.id],
targetDocumentId: target.trim(),
}),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "移动失败");
return;
}
await sidebarQuery.refetch();
setAssetMenu(null);
emitAssetsChanged(asset.document_id);
emitAssetsChanged(target.trim());
},
[sidebarQuery],
);
const handleDeleteAssets = useCallback(
async (assetIds: string[], assetHint?: MediaAsset) => {
const target =
assetHint ??
mediaAssets.find((item) => assetIds.includes(item.id)) ??
null;
if (target?.asset_type === "mindmap") {
const resp = await fetch(`/api/mindmap/${target.document_id}`, { method: "DELETE" });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除思维导图失败");
return;
}
setMindmapDocs((prev) => prev.filter((id) => id !== target.document_id));
setAssetMenu(null);
emitAssetsChanged(target.document_id, undefined, undefined, true);
return;
}
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除失败");
return;
}
setMediaAssets((prev) => prev.filter((item) => !assetIds.includes(item.id)));
setAssetMenu(null);
emitAssetsChanged(target?.document_id, undefined, assetIds);
},
[mediaAssets, sidebarQuery],
);
const handleResizeStart = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
@@ -389,8 +716,12 @@ export function Sidebar({ initialData }: SidebarProps) {
body: JSON.stringify({ documentId }),
});
await refreshTree();
emitDocumentsChanged(documentId);
if (activeId === documentId) {
router.push("/");
}
},
[refreshTree],
[activeId, refreshTree, router],
);
const handleConvertToChild = useCallback(
@@ -713,6 +1044,11 @@ export function Sidebar({ initialData }: SidebarProps) {
onOpenAsset={handleOpenAsset}
onCreateChild={handleCreate}
onContextMenu={openContextMenu}
onAssetContextMenu={handleAssetContextMenu}
selectedAssetIds={selectedAssetIds}
onAssetClick={(asset, e) => handleSelectAsset(asset, e)}
onToggleAssetSelect={toggleAssetCheckbox}
onSelectOnlyAsset={selectOnlyAsset}
/>
</div>
</div>
@@ -772,6 +1108,25 @@ export function Sidebar({ initialData }: SidebarProps) {
onDelete={() => void handleDelete(contextMenu.node.id)}
/>
)}
{assetMenu && (
<AssetContextMenu
asset={assetMenu.asset}
position={{ x: assetMenu.x, y: assetMenu.y }}
onClose={() => setAssetMenu(null)}
onOpen={handleOpenAsset}
onCopyLink={handleCopyAssetLink}
onCopyPath={handleCopyAssetPath}
onRename={handleRenameAsset}
onMove={handleMoveAsset}
onDelete={(ids) =>
void handleDeleteAssets(
selectedAssetIds.size > 0 ? Array.from(selectedAssetIds) : ids,
assetMenu.asset,
)
}
onDownload={handleDownloadAsset}
/>
)}
<Drawer open={trashOpen} onOpenChange={setTrashOpen}>
<DrawerContent className="max-h-[90vh]">
<DrawerHeader className="text-left">
@@ -1,5 +1,6 @@
import type { DocumentRecord } from "@/lib/documents";
import type { WorkspaceSummary } from "@/lib/workspaces";
import type { MediaAsset } from "@/types/media";
export type SidebarSectionId = "starred" | "public" | "shared" | "private" | "templates";
@@ -16,4 +17,12 @@ export interface SidebarInitialData {
workspaces: WorkspaceSummary[];
documents: DocumentRecord[];
trashedDocuments: TrashRecord[];
/**
* 已存在思维导图文件(本地或 supabase)对应的页面 id 列表
*/
mindmapDocs?: string[];
/**
* 可选的媒体资源列表(旧字段向后兼容)
*/
mediaAssets?: MediaAsset[];
}
+23
View File
@@ -0,0 +1,23 @@
/**
* 侧边栏与编辑区之间的轻量事件总线
*/
export const ASSETS_CHANGED_EVENT = "wolai:assets-changed";
export const DOCUMENTS_CHANGED_EVENT = "wolai:documents-changed";
type AssetsChangedPayload = {
docId?: string;
asset?: unknown;
assetIds?: string[];
mindmapDeleted?: boolean;
};
export function emitAssetsChanged(docId?: string, asset?: unknown, assetIds?: string[], mindmapDeleted?: boolean) {
if (typeof window === "undefined") return;
const detail: AssetsChangedPayload = { docId, asset, assetIds, mindmapDeleted };
window.dispatchEvent(new CustomEvent(ASSETS_CHANGED_EVENT, { detail }));
}
export function emitDocumentsChanged(docId?: string) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(DOCUMENTS_CHANGED_EVENT, { detail: { docId } }));
}
+28
View File
@@ -0,0 +1,28 @@
import path from "path";
import { promises as fs } from "fs";
const preferredBaseDir = path.join(process.cwd(), "public", "documents");
const legacyBaseDir = path.join(process.cwd(), "public", "mindmaps");
const tryAccess = async (file: string) => {
try {
await fs.access(file);
return true;
} catch {
return false;
}
};
export async function detectLocalMindmapDocs(docIds: string[]): Promise<string[]> {
const results: string[] = [];
for (const id of docIds) {
const preferred = path.join(preferredBaseDir, id, "mindmap.json");
const legacy = path.join(legacyBaseDir, id, "mindmap.json");
if ((await tryAccess(preferred)) || (await tryAccess(legacy))) {
results.push(id);
}
}
return results;
}
export { preferredBaseDir, legacyBaseDir };
+49 -24
View File
@@ -1,40 +1,31 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { DocumentRecord, DocumentNode } from "@/lib/documents";
import type { SidebarSectionId, TrashRecord } from "@/components/sidebar/types";
import type { Database } from "@/types/supabase";
import type { MediaAsset } from "@/types/media";
import { buildDocumentTree } from "@/lib/documents";
type TypedClient = SupabaseClient<Database>;
export interface SidebarSectionSnapshot {
id: SidebarSectionId;
title: string;
icon?: string;
nodes: DocumentNode[];
export interface SidebarDataset {
documents: DocumentRecord[];
trashedDocuments: TrashRecord[];
/**
* 仅依据远端 mindmap_data 判定的页面 id 列表;本地文件检测由服务器侧辅助完成
*/
mindmapDocs: string[];
mediaAssets?: MediaAsset[];
}
export interface FlattenedTreeNode {
node: DocumentNode;
depth: number;
parentId: string | null;
}
const SECTION_META: Record<SidebarSectionId, { title: string; icon: string }> = {
starred: { title: "星标置顶", icon: "star" },
public: { title: "公共页面", icon: "globe" },
shared: { title: "共享页面", icon: "users" },
private: { title: "私有 / 我的页面", icon: "lock" },
templates: { title: "模板中心", icon: "grid" },
};
export async function fetchSidebarDataset(
client: TypedClient,
workspaceId: string,
): Promise<{ documents: DocumentRecord[]; trashedDocuments: TrashRecord[] }> {
): Promise<SidebarDataset> {
const { data: documentRows, error } = await client
.from("documents")
.select(
"id,title,parent_id,sort_order,is_starred,access_scope,is_template,created_at,updated_at,workspace_id",
"id,title,parent_id,sort_order,is_starred,access_scope,is_template,created_at,updated_at,workspace_id,mindmap_data",
)
.eq("workspace_id", workspaceId)
.is("deleted_at", null)
@@ -78,9 +69,26 @@ export async function fetchSidebarDataset(
access_scope: (row.access_scope ?? "private") as DocumentRecord["access_scope"],
}));
const mindmapDocs =
documentRows?.filter((row) => row.mindmap_data != null).map((row) => row.id) ?? [];
const { data: assetRows, error: assetError } = await client
.from("media_assets")
.select("*")
.eq("workspace_id", workspaceId)
.order("created_at", { ascending: false });
if (assetError) {
throw new Error(`获取附件列表失败:${assetError.message}`);
}
const mediaAssets: MediaAsset[] = (assetRows ?? []) as MediaAsset[];
return {
documents,
trashedDocuments,
mindmapDocs,
mediaAssets,
};
}
@@ -107,8 +115,8 @@ export function flattenDocumentTree(
expanded: Set<string>,
depth = 0,
parentId: string | null = null,
): FlattenedTreeNode[] {
const list: FlattenedTreeNode[] = [];
): Array<{ node: DocumentNode; depth: number; parentId: string | null }> {
const list: Array<{ node: DocumentNode; depth: number; parentId: string | null }> = [];
nodes.forEach((node) => {
list.push({ node, depth, parentId });
if (node.children.length > 0 && expanded.has(node.id)) {
@@ -118,12 +126,29 @@ export function flattenDocumentTree(
return list;
}
const SECTION_META: Record<SidebarSectionId, { title: string; icon: string }> = {
starred: { title: "星标置顶", icon: "star" },
public: { title: "公共页面", icon: "globe" },
shared: { title: "共享页面", icon: "users" },
private: { title: "私有 / 我的页面", icon: "lock" },
templates: { title: "模板中心", icon: "grid" },
};
type SidebarSectionSnapshot = {
id: SidebarSectionId;
title: string;
icon?: string;
nodes: DocumentNode[];
};
export function buildSidebarSections(records: DocumentRecord[]): SidebarSectionSnapshot[] {
const tree = buildDocumentTree(records);
return buildSidebarSectionsFromTree(tree);
}
export function buildSidebarSectionsFromTree(tree: DocumentNode[]): SidebarSectionSnapshot[] {
export function buildSidebarSectionsFromTree(
tree: DocumentNode[],
): SidebarSectionSnapshot[] {
const sections: Array<{ id: SidebarSectionId; predicate: NodePredicate }> = [
{ id: "starred", predicate: (node) => Boolean(node.is_starred) },
{ id: "public", predicate: (node) => node.access_scope === "public" },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 99 KiB