双向删除同步
This commit is contained in:
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+22
-8641
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspace
|
|||||||
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
|
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
|
||||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||||
import { SearchPalette } from "@/components/search/search-palette";
|
import { SearchPalette } from "@/components/search/search-palette";
|
||||||
|
import { detectLocalMindmapDocs } from "@/lib/mindmap-files";
|
||||||
|
|
||||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||||
const supabase = await createSupabaseServerClient();
|
const supabase = await createSupabaseServerClient();
|
||||||
@@ -30,12 +31,19 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
if (activeWorkspaceId) {
|
if (activeWorkspaceId) {
|
||||||
const dataset = await fetchSidebarDataset(supabase, activeWorkspaceId);
|
const dataset = await fetchSidebarDataset(supabase, activeWorkspaceId);
|
||||||
documents = dataset.documents;
|
documents = dataset.documents;
|
||||||
|
const localMindmaps = await detectLocalMindmapDocs(
|
||||||
|
dataset.documents.map((d) => d.id),
|
||||||
|
);
|
||||||
|
const mindmapDocs = Array.from(
|
||||||
|
new Set([...(dataset.mindmapDocs ?? []), ...localMindmaps]),
|
||||||
|
);
|
||||||
sidebarInitialData = {
|
sidebarInitialData = {
|
||||||
activeWorkspaceId,
|
activeWorkspaceId,
|
||||||
workspaces,
|
workspaces,
|
||||||
documents: dataset.documents,
|
documents: dataset.documents,
|
||||||
trashedDocuments: dataset.trashedDocuments,
|
trashedDocuments: dataset.trashedDocuments,
|
||||||
mediaAssets: dataset.mediaAssets,
|
mediaAssets: dataset.mediaAssets,
|
||||||
|
mindmapDocs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
|||||||
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
|
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
|
||||||
import type { Json } from "@/types/supabase";
|
import type { Json } from "@/types/supabase";
|
||||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
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) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
@@ -95,6 +112,11 @@ async function handleCreateRequest(request: Request) {
|
|||||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 为文件树创建本地目录和 index.md
|
||||||
|
if (data?.id) {
|
||||||
|
await ensureDocumentScaffold(data.id, data.title ?? "无标题");
|
||||||
|
}
|
||||||
|
|
||||||
if (parentId && data) {
|
if (parentId && data) {
|
||||||
const existingBlocks = extractBlocksFromContent(parentContent);
|
const existingBlocks = extractBlocksFromContent(parentContent);
|
||||||
const pageReferenceBlock = {
|
const pageReferenceBlock = {
|
||||||
|
|||||||
@@ -1,10 +1,36 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import path from "path";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
|
||||||
interface DuplicatePayload {
|
interface DuplicatePayload {
|
||||||
documentId: string;
|
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) {
|
export async function POST(request: Request) {
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
@@ -16,7 +42,6 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { documentId }: DuplicatePayload = await request.json();
|
const { documentId }: DuplicatePayload = await request.json();
|
||||||
|
|
||||||
if (!documentId) {
|
if (!documentId) {
|
||||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
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;
|
const { count: siblingCount = 0, error: countError } = await siblingQuery;
|
||||||
|
|
||||||
if (countError) {
|
if (countError) {
|
||||||
return NextResponse.json({ error: countError.message }, { status: 500 });
|
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 duplicatedTitle = `${fallbackTitle} 副本`;
|
||||||
|
|
||||||
const { data: duplicated, error: duplicateError } = await supabase
|
const { data: duplicated, error: duplicateError } = await supabase
|
||||||
@@ -67,8 +94,14 @@ export async function POST(request: Request) {
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (duplicateError || !duplicated) {
|
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);
|
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 { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspaces";
|
||||||
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
|
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
|
||||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||||
|
import { detectLocalMindmapDocs } from "@/lib/mindmap-files";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -29,12 +30,20 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const dataset = await fetchSidebarDataset(supabase, targetWorkspaceId);
|
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 = {
|
const payload: SidebarInitialData = {
|
||||||
activeWorkspaceId: targetWorkspaceId,
|
activeWorkspaceId: targetWorkspaceId,
|
||||||
workspaces,
|
workspaces,
|
||||||
documents: dataset.documents,
|
documents: dataset.documents,
|
||||||
trashedDocuments: dataset.trashedDocuments,
|
trashedDocuments: dataset.trashedDocuments,
|
||||||
|
mindmapDocs,
|
||||||
|
mediaAssets: dataset.mediaAssets ?? [],
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(payload);
|
return NextResponse.json(payload);
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
|||||||
import type { DocumentTableSnapshot, TableRowData } from "@/types/online-table";
|
import type { DocumentTableSnapshot, TableRowData } from "@/types/online-table";
|
||||||
|
|
||||||
interface RouteContext {
|
interface RouteContext {
|
||||||
params: {
|
params: Promise<{
|
||||||
tableId: string;
|
tableId: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateTableRequest = {
|
type UpdateTableRequest = {
|
||||||
@@ -19,9 +19,7 @@ type UpdateTableRequest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const extractTableId = async (context: RouteContext) => {
|
const extractTableId = async (context: RouteContext) => {
|
||||||
// Next.js 16 / Turbopack 下 params 是 Promise,需要等待。
|
return (await context.params).tableId;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return (await (context as any).params).tableId as string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAuthUser = async () => {
|
const getAuthUser = async () => {
|
||||||
@@ -31,7 +29,7 @@ const getAuthUser = async () => {
|
|||||||
console.error("Auth getUser error:", error);
|
console.error("Auth getUser error:", error);
|
||||||
}
|
}
|
||||||
if (!data?.user) {
|
if (!data?.user) {
|
||||||
return { supabase, user: null as const };
|
return { supabase, user: null };
|
||||||
}
|
}
|
||||||
return { supabase, user: data.user };
|
return { supabase, user: data.user };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import "@blocknote/core/style.css";
|
|||||||
import "@blocknote/react/style.css";
|
import "@blocknote/react/style.css";
|
||||||
import "@blocknote/mantine/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 { BlockNoteView } from "@blocknote/mantine";
|
||||||
import {
|
import {
|
||||||
SideMenuController,
|
SideMenuController,
|
||||||
@@ -27,6 +27,7 @@ import { useSearchPaletteStore } from "@/store/search-palette";
|
|||||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||||
import type { ReferenceTarget } from "@/types/search";
|
import type { ReferenceTarget } from "@/types/search";
|
||||||
import FullScreenTableEditor from "@/components/online-table/FullScreenTableEditor";
|
import FullScreenTableEditor from "@/components/online-table/FullScreenTableEditor";
|
||||||
|
import { ASSETS_CHANGED_EVENT, emitAssetsChanged } from "@/lib/events";
|
||||||
|
|
||||||
interface BlockNoteEditorProps {
|
interface BlockNoteEditorProps {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -235,6 +236,98 @@ export function BlockNoteEditor({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const debouncedSave = useDebouncedCallback(saveContent, 800);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
@@ -254,6 +347,19 @@ export function BlockNoteEditor({
|
|||||||
const stats = computeDocumentStats(typedBlocks);
|
const stats = computeDocumentStats(typedBlocks);
|
||||||
onStatsChange?.(stats);
|
onStatsChange?.(stats);
|
||||||
onSnapshot?.({ blocks: blocks as Json, 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();
|
runSync();
|
||||||
@@ -264,7 +370,7 @@ export function BlockNoteEditor({
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [editor, debouncedSave, onSnapshot, onStatsChange]);
|
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmap, editor, onSnapshot, onStatsChange]);
|
||||||
|
|
||||||
const jumpToHeading = useCallback((headingId: string) => {
|
const jumpToHeading = useCallback((headingId: string) => {
|
||||||
const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
|
const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
|
||||||
@@ -386,6 +492,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
|||||||
fileSize: asset.file_size ?? null,
|
fileSize: asset.file_size ?? null,
|
||||||
mimeType: asset.mime_type ?? "",
|
mimeType: asset.mime_type ?? "",
|
||||||
ocrStatus: asset.ocr_status ?? "idle",
|
ocrStatus: asset.ocr_status ?? "idle",
|
||||||
|
documentId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -393,7 +500,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
|||||||
"after",
|
"after",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[editor],
|
[documentId, editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadClipboardMedia = useCallback(
|
const uploadClipboardMedia = useCallback(
|
||||||
@@ -417,6 +524,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
|||||||
if (!payload.asset) {
|
if (!payload.asset) {
|
||||||
throw new Error("上传返回数据缺失");
|
throw new Error("上传返回数据缺失");
|
||||||
}
|
}
|
||||||
|
emitAssetsChanged(documentId, payload.asset);
|
||||||
return payload.asset;
|
return payload.asset;
|
||||||
},
|
},
|
||||||
[documentId, workspaceId],
|
[documentId, workspaceId],
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useCallback,
|
||||||
type JSX,
|
type JSX,
|
||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createReactBlockSpec } from "@blocknote/react";
|
import { createReactBlockSpec } from "@blocknote/react";
|
||||||
import type { Block, BlockNoteEditor } from "@blocknote/core";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useImagePicker } from "@/components/media/image-picker-context";
|
import { useImagePicker } from "@/components/media/image-picker-context";
|
||||||
import type { MediaKind } from "@/types/media";
|
import type { MediaKind } from "@/types/media";
|
||||||
|
import { emitAssetsChanged } from "@/lib/events";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -96,6 +98,16 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
|
|||||||
const [captionEditing, setCaptionEditing] = useState(false);
|
const [captionEditing, setCaptionEditing] = useState(false);
|
||||||
const shouldShowCaption = captionEditing || Boolean(block.props.caption);
|
const shouldShowCaption = captionEditing || Boolean(block.props.caption);
|
||||||
const officeBase = process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL;
|
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 extension = useMemo(() => {
|
||||||
const name = (block.props.fileName || deriveFileName(fileUrl)).toLowerCase();
|
const name = (block.props.fileName || deriveFileName(fileUrl)).toLowerCase();
|
||||||
const match = /\.([a-z0-9]+)$/.exec(name);
|
const match = /\.([a-z0-9]+)$/.exec(name);
|
||||||
@@ -124,6 +136,7 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
|
|||||||
fileSize: selection.fileSize ?? block.props.fileSize ?? null,
|
fileSize: selection.fileSize ?? block.props.fileSize ?? null,
|
||||||
mimeType: selection.mimeType ?? block.props.mimeType ?? "",
|
mimeType: selection.mimeType ?? block.props.mimeType ?? "",
|
||||||
ocrStatus: "idle",
|
ocrStatus: "idle",
|
||||||
|
documentId: resolveDocumentId(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -255,6 +268,27 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
|
|||||||
anchor.click();
|
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 () => {
|
const triggerOcr = async () => {
|
||||||
if (!block.props.assetId) {
|
if (!block.props.assetId) {
|
||||||
window.alert("请先上传图片后再执行 OCR");
|
window.alert("请先上传图片后再执行 OCR");
|
||||||
@@ -395,6 +429,12 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
|
|||||||
icon: <Download className="h-4 w-4" />,
|
icon: <Download className="h-4 w-4" />,
|
||||||
onClick: downloadAsset,
|
onClick: downloadAsset,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: `删除${typeLabel}`,
|
||||||
|
icon: <Trash className="h-4 w-4" />,
|
||||||
|
onClick: handleDeleteAsset,
|
||||||
|
},
|
||||||
].filter((action): action is QuickAction => Boolean(action));
|
].filter((action): action is QuickAction => Boolean(action));
|
||||||
|
|
||||||
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
|
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
|
||||||
@@ -504,6 +544,7 @@ export const mediaBlock = createReactBlockSpec(
|
|||||||
mimeType: { default: "", type: "string" },
|
mimeType: { default: "", type: "string" },
|
||||||
width: { default: 0, type: "number" },
|
width: { default: 0, type: "number" },
|
||||||
ocrStatus: { default: "idle", type: "string" },
|
ocrStatus: { default: "idle", type: "string" },
|
||||||
|
documentId: { default: "", type: "string" },
|
||||||
},
|
},
|
||||||
content: "none",
|
content: "none",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,9 +17,15 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from "lucide-react";
|
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from "lucide-react";
|
||||||
import iconConfig from "./mindmapIconConfig";
|
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 第三方库缺少类型定义
|
// @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> =>
|
const getImageSizeSafe = (url: string): Promise<{ width: number; height: number } | null> =>
|
||||||
@@ -54,7 +60,19 @@ type MindMapInstance = {
|
|||||||
setLayout: (layout: string) => void;
|
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: "中心主题" },
|
data: { text: "中心主题" },
|
||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
@@ -77,6 +95,39 @@ const createSimplePrompt = (title: string, placeholder = "") => {
|
|||||||
return value.trim();
|
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 = (
|
const applyToActiveNodes = (
|
||||||
mindmap: MindMapInstance | null,
|
mindmap: MindMapInstance | null,
|
||||||
handler: (node: unknown) => void,
|
handler: (node: unknown) => void,
|
||||||
@@ -134,7 +185,6 @@ const MindmapBlockView = ({
|
|||||||
const [canBack, setCanBack] = useState(false);
|
const [canBack, setCanBack] = useState(false);
|
||||||
const [canForward, setCanForward] = useState(false);
|
const [canForward, setCanForward] = useState(false);
|
||||||
const [activeNodes, setActiveNodes] = useState<unknown[]>([]);
|
const [activeNodes, setActiveNodes] = useState<unknown[]>([]);
|
||||||
const activeCount = activeNodes.length;
|
|
||||||
const [painterMode, setPainterMode] = useState(false);
|
const [painterMode, setPainterMode] = useState(false);
|
||||||
const [showMiniMap, setShowMiniMap] = useState(false);
|
const [showMiniMap, setShowMiniMap] = useState(false);
|
||||||
const [activeSidebar, setActiveSidebar] = useState<SidebarPanel | null>(null);
|
const [activeSidebar, setActiveSidebar] = useState<SidebarPanel | null>(null);
|
||||||
@@ -146,7 +196,19 @@ const MindmapBlockView = ({
|
|||||||
return instance?.renderer?.root ?? instance?.renderer?.renderTree?._node ?? null;
|
return instance?.renderer?.root ?? instance?.renderer?.renderTree?._node ?? null;
|
||||||
}, [mindmap]);
|
}, [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);
|
const initialDataRef = useRef<unknown>(null);
|
||||||
if (initialDataRef.current === null) {
|
if (initialDataRef.current === null) {
|
||||||
const cached = typeof window !== "undefined" ? window.localStorage.getItem(autosaveKey) : 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(
|
const persistData = useCallback(
|
||||||
(data: unknown) => {
|
(data: unknown) => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
window.localStorage.setItem(autosaveKey, JSON.stringify(data));
|
window.localStorage.setItem(autosaveKey, JSON.stringify(data));
|
||||||
editor.updateBlock(block, { props: { ...block.props, 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) => {
|
const debouncedPersist = useDebouncedCallback((data: unknown) => {
|
||||||
persistData(data);
|
persistData(data);
|
||||||
}, 500);
|
}, 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(() => {
|
useEffect(() => {
|
||||||
if (!mindmap || activeNodes.length > 0) return;
|
if (!mindmap || activeNodes.length > 0) return;
|
||||||
@@ -204,6 +323,16 @@ const MindmapBlockView = ({
|
|||||||
{ default: Drag },
|
{ default: Drag },
|
||||||
{ default: KeyboardNavigation },
|
{ default: KeyboardNavigation },
|
||||||
{ default: NodeImgAdjust },
|
{ 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([
|
] = await Promise.all([
|
||||||
import("simple-mind-map"),
|
import("simple-mind-map"),
|
||||||
import("simple-mind-map/src/plugins/Painter.js"),
|
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/Drag.js"),
|
||||||
import("simple-mind-map/src/plugins/KeyboardNavigation.js"),
|
import("simple-mind-map/src/plugins/KeyboardNavigation.js"),
|
||||||
import("simple-mind-map/src/plugins/NodeImgAdjust.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 = [
|
const plugins = [
|
||||||
@@ -231,6 +370,16 @@ const MindmapBlockView = ({
|
|||||||
{ name: "Drag", plugin: Drag },
|
{ name: "Drag", plugin: Drag },
|
||||||
{ name: "KeyboardNavigation", plugin: KeyboardNavigation },
|
{ name: "KeyboardNavigation", plugin: KeyboardNavigation },
|
||||||
{ name: "NodeImgAdjust", plugin: NodeImgAdjust },
|
{ 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 }) => {
|
plugins.forEach(({ name, plugin }) => {
|
||||||
@@ -241,13 +390,23 @@ const MindmapBlockView = ({
|
|||||||
// @ts-expect-error 第三方库缺少类型定义
|
// @ts-expect-error 第三方库缺少类型定义
|
||||||
if (MindMap.hasPlugin(plugin) === -1) {
|
if (MindMap.hasPlugin(plugin) === -1) {
|
||||||
console.log(`注册插件: ${name}`);
|
console.log(`注册插件: ${name}`);
|
||||||
// @ts-expect-error 第三方库缺少类型定义
|
const registerPlugin =
|
||||||
MindMap.usePlugin(plugin);
|
// @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({
|
const instance = new MindMap({
|
||||||
el: containerRef.current,
|
el: hostEl,
|
||||||
data: initialDataRef.current,
|
data: initialDataRef.current,
|
||||||
theme: "classic",
|
theme: "classic",
|
||||||
layout: "logicalStructure",
|
layout: "logicalStructure",
|
||||||
@@ -262,6 +421,17 @@ const MindmapBlockView = ({
|
|||||||
...(iconConfig as unknown[]),
|
...(iconConfig as unknown[]),
|
||||||
]),
|
]),
|
||||||
}) as MindMapInstance;
|
}) 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");
|
instance.setMode?.("edit");
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -284,15 +454,15 @@ const MindmapBlockView = ({
|
|||||||
// 确保点击即可激活(调用节点自身的 active 逻辑,保持和官方一致)
|
// 确保点击即可激活(调用节点自身的 active 逻辑,保持和官方一致)
|
||||||
// @ts-expect-error 第三方库节点对象
|
// @ts-expect-error 第三方库节点对象
|
||||||
if (typeof node?.active === "function") {
|
if (typeof node?.active === "function") {
|
||||||
// @ts-expect-error 第三方库节点对象
|
// @ts-expect-error simple-mind-map 节点对象缺少类型
|
||||||
node.active();
|
node.active();
|
||||||
} else {
|
} else {
|
||||||
// 兜底:手动维护激活列表
|
// 兜底:手动维护激活列表
|
||||||
// @ts-expect-error 第三方库缺少类型定义
|
// @ts-expect-error simple-mind-map 渲染器缺少类型
|
||||||
instance.renderer?.clearActiveNodeList?.();
|
instance.renderer?.clearActiveNodeList?.();
|
||||||
// @ts-expect-error
|
// @ts-expect-error simple-mind-map 渲染器缺少类型
|
||||||
instance.renderer?.addNodeToActiveList?.(node, true);
|
instance.renderer?.addNodeToActiveList?.(node, true);
|
||||||
// @ts-expect-error
|
// @ts-expect-error simple-mind-map 渲染器缺少类型
|
||||||
instance.renderer?.emitNodeActiveEvent?.(node);
|
instance.renderer?.emitNodeActiveEvent?.(node);
|
||||||
}
|
}
|
||||||
const list = instance.renderer?.activeNodeList ?? [];
|
const list = instance.renderer?.activeNodeList ?? [];
|
||||||
@@ -374,9 +544,7 @@ const MindmapBlockView = ({
|
|||||||
// 图片预览(双击节点图片)
|
// 图片预览(双击节点图片)
|
||||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||||
const [viewerSrc, setViewerSrc] = useState("");
|
const [viewerSrc, setViewerSrc] = useState("");
|
||||||
const [viewerNode, setViewerNode] = useState<any>(null);
|
|
||||||
const [viewerMeta, setViewerMeta] = useState<{ title?: string; width?: number; height?: number }>({});
|
const [viewerMeta, setViewerMeta] = useState<{ title?: string; width?: number; height?: number }>({});
|
||||||
const [hasActiveImg, setHasActiveImg] = useState(false);
|
|
||||||
const imgToolbarRef = useRef<HTMLDivElement | null>(null);
|
const imgToolbarRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [imgToolbarState, setImgToolbarState] = useState({
|
const [imgToolbarState, setImgToolbarState] = useState({
|
||||||
show: false,
|
show: false,
|
||||||
@@ -387,7 +555,7 @@ const MindmapBlockView = ({
|
|||||||
const imgToolbarHover = useRef(false);
|
const imgToolbarHover = useRef(false);
|
||||||
|
|
||||||
const handleImage = () => {
|
const handleImage = () => {
|
||||||
const first = mindmap?.renderer?.activeNodeList?.[0] as any;
|
const first = mindmap?.renderer?.activeNodeList?.[0] as MindMapNode | undefined;
|
||||||
if (first?.getStyle) {
|
if (first?.getStyle) {
|
||||||
const placement = first.getStyle("imgPlacement", false) as string;
|
const placement = first.getStyle("imgPlacement", false) as string;
|
||||||
if (placement) setImagePosition(placement);
|
if (placement) setImagePosition(placement);
|
||||||
@@ -398,7 +566,7 @@ const MindmapBlockView = ({
|
|||||||
// 预览:监听 mindmap 事件
|
// 预览:监听 mindmap 事件
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mindmap) return;
|
if (!mindmap) return;
|
||||||
const handler = (node: any, e: any) => {
|
const handler = (node: MindMapNode, e?: Event) => {
|
||||||
e?.stopPropagation?.();
|
e?.stopPropagation?.();
|
||||||
e?.preventDefault?.();
|
e?.preventDefault?.();
|
||||||
const src =
|
const src =
|
||||||
@@ -407,7 +575,6 @@ const MindmapBlockView = ({
|
|||||||
node?.data?.image;
|
node?.data?.image;
|
||||||
if (src) {
|
if (src) {
|
||||||
setViewerSrc(src);
|
setViewerSrc(src);
|
||||||
setViewerNode(node);
|
|
||||||
const size = node?.getData?.("imageSize") || {};
|
const size = node?.getData?.("imageSize") || {};
|
||||||
const title = node?.getData?.("imageTitle") || "";
|
const title = node?.getData?.("imageTitle") || "";
|
||||||
setViewerMeta({
|
setViewerMeta({
|
||||||
@@ -419,14 +586,14 @@ const MindmapBlockView = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onActive = () => {
|
const onActive = () => {
|
||||||
const list = mindmap.renderer?.activeNodeList || [];
|
const list = (mindmap.renderer?.activeNodeList || []) as MindMapNode[];
|
||||||
const has = list.some((n: any) => !!n?.getData?.("image"));
|
const has = list.some((n) => !!n?.getData?.("image"));
|
||||||
setHasActiveImg(has);
|
|
||||||
if (!has) setImgToolbarState((s) => ({ ...s, show: false }));
|
if (!has) setImgToolbarState((s) => ({ ...s, show: false }));
|
||||||
};
|
};
|
||||||
const showToolbarOnClick = (node: any, svgImg: any, evt: any) => {
|
const showToolbarOnClick = (node: MindMapNode, _svgImg: unknown, evt: Event | undefined) => {
|
||||||
const bbox = evt?.target?.getBoundingClientRect?.();
|
const target = evt?.target as Element | undefined;
|
||||||
const placement = (node?.getStyle?.("imgPlacement") || "top") as any;
|
const bbox = target?.getBoundingClientRect?.();
|
||||||
|
const placement = (node?.getStyle?.("imgPlacement") || "top") as "top" | "bottom" | "left" | "right";
|
||||||
if (!bbox) return;
|
if (!bbox) return;
|
||||||
setImagePosition(placement);
|
setImagePosition(placement);
|
||||||
setImgToolbarState({
|
setImgToolbarState({
|
||||||
@@ -464,7 +631,11 @@ const MindmapBlockView = ({
|
|||||||
);
|
);
|
||||||
setImgToolbarState((s) => ({ ...s, placement: p }));
|
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
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -571,24 +742,64 @@ const MindmapBlockView = ({
|
|||||||
window.alert("AI 能力占位:后续接入大模型生成/优化节点内容。");
|
window.alert("AI 能力占位:后续接入大模型生成/优化节点内容。");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const reader = new FileReader();
|
const reset = () => {
|
||||||
reader.onload = () => {
|
event.target.value = "";
|
||||||
try {
|
};
|
||||||
const data = JSON.parse(String(reader.result));
|
const ext = (file.name.split(".").pop() || "").toLowerCase();
|
||||||
|
try {
|
||||||
|
// JSON / smm
|
||||||
|
if (ext === "json" || ext === "smm") {
|
||||||
|
const text = await file.text();
|
||||||
|
const data = JSON.parse(text);
|
||||||
mindmap?.setData(data);
|
mindmap?.setData(data);
|
||||||
mindmap?.command.clearHistory();
|
mindmap?.command.clearHistory();
|
||||||
debouncedPersist(data);
|
debouncedPersist(data);
|
||||||
} catch (error) {
|
return;
|
||||||
console.error(error);
|
|
||||||
window.alert("导入失败:文件格式错误");
|
|
||||||
} finally {
|
|
||||||
event.target.value = "";
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
reader.readAsText(file, "utf-8");
|
// 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("导入失败:文件格式或内容错误");
|
||||||
|
} finally {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNew = () => {
|
const handleNew = () => {
|
||||||
@@ -618,21 +829,49 @@ const MindmapBlockView = ({
|
|||||||
downloadJson(data, "mindmap");
|
downloadJson(data, "mindmap");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportPng = async () => {
|
const handleExport = async (type: string, name = "mindmap") => {
|
||||||
try {
|
try {
|
||||||
await mindmap?.doExport?.export("png", true, "mindmap");
|
await mindmap?.doExport?.export(type, true, name);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(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 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 = {
|
const toolbarProps = {
|
||||||
canBack,
|
canBack,
|
||||||
canForward,
|
canForward,
|
||||||
activeCount,
|
|
||||||
painterMode,
|
painterMode,
|
||||||
onUndo: handleUndo,
|
onUndo: handleUndo,
|
||||||
onRedo: handleRedo,
|
onRedo: handleRedo,
|
||||||
@@ -655,8 +894,14 @@ const MindmapBlockView = ({
|
|||||||
onNew: handleNew,
|
onNew: handleNew,
|
||||||
onOpenDirectory: handleOpenDirectory,
|
onOpenDirectory: handleOpenDirectory,
|
||||||
onSaveAs: handleSaveAs,
|
onSaveAs: handleSaveAs,
|
||||||
|
onDeleteMindmap: handleDeleteMindmap,
|
||||||
onExportJson: handleExportJson,
|
onExportJson: handleExportJson,
|
||||||
onExportPng: handleExportPng,
|
onExportPng: handleExportPng,
|
||||||
|
onExportSvg: handleExportSvg,
|
||||||
|
onExportPdf: handleExportPdf,
|
||||||
|
onExportMd: handleExportMd,
|
||||||
|
onExportTxt: handleExportTxt,
|
||||||
|
onExportXmind: handleExportXmind,
|
||||||
fileInputRef,
|
fileInputRef,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -711,6 +956,7 @@ const MindmapBlockView = ({
|
|||||||
<span className="truncate">{viewerMeta.title || "图片预览"}</span>
|
<span className="truncate">{viewerMeta.title || "图片预览"}</span>
|
||||||
<span className="shrink-0">{viewerMeta.width && viewerMeta.height ? `${viewerMeta.width} × ${viewerMeta.height}px` : ""}</span>
|
<span className="shrink-0">{viewerMeta.width && viewerMeta.height ? `${viewerMeta.width} × ${viewerMeta.height}px` : ""}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={viewerSrc}
|
src={viewerSrc}
|
||||||
alt={viewerMeta.title || "预览"}
|
alt={viewerMeta.title || "预览"}
|
||||||
@@ -884,7 +1130,11 @@ const MindmapBlockView = ({
|
|||||||
<MindmapToolbar {...toolbarProps} />
|
<MindmapToolbar {...toolbarProps} />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative h-full w-full">
|
<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
|
<MindmapSidebarTrigger
|
||||||
activeSidebar={activeSidebar}
|
activeSidebar={activeSidebar}
|
||||||
@@ -923,7 +1173,11 @@ const MindmapBlockView = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative h-[520px] w-full overflow-hidden bg-white">
|
<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
|
<MindmapSidebarTrigger
|
||||||
activeSidebar={activeSidebar}
|
activeSidebar={activeSidebar}
|
||||||
@@ -960,6 +1214,7 @@ export const mindmapBlock = createReactBlockSpec(
|
|||||||
{
|
{
|
||||||
type: "mindmap",
|
type: "mindmap",
|
||||||
propSchema: {
|
propSchema: {
|
||||||
|
docId: { default: "" },
|
||||||
data: { default: defaultMindmapData },
|
data: { default: defaultMindmapData },
|
||||||
},
|
},
|
||||||
content: "none",
|
content: "none",
|
||||||
|
|||||||
@@ -1,28 +1,14 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Toggle } from "@/components/ui/toggle";
|
import { Toggle } from "@/components/ui/toggle";
|
||||||
import {
|
import { Bold, Italic, Underline, Type, Strikethrough, Palette, X, Network } from "lucide-react";
|
||||||
Bold,
|
|
||||||
Italic,
|
|
||||||
Underline,
|
|
||||||
Type,
|
|
||||||
Strikethrough,
|
|
||||||
Palette,
|
|
||||||
X,
|
|
||||||
Network,
|
|
||||||
ListTree,
|
|
||||||
Sliders,
|
|
||||||
Calculator,
|
|
||||||
StickyNote,
|
|
||||||
Bot,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
fontFamilyList,
|
fontFamilyList,
|
||||||
fontSizeList,
|
fontSizeList,
|
||||||
lineHeightList,
|
lineHeightList,
|
||||||
colorList,
|
|
||||||
borderDasharrayList,
|
borderDasharrayList,
|
||||||
borderRadiusList,
|
borderRadiusList,
|
||||||
borderWidthList,
|
borderWidthList,
|
||||||
@@ -38,6 +24,7 @@ import {
|
|||||||
} from "./mindmapOptions";
|
} from "./mindmapOptions";
|
||||||
import iconConfig from "./mindmapIconConfig";
|
import iconConfig from "./mindmapIconConfig";
|
||||||
import imageConfig from "./mindmapImageConfig";
|
import imageConfig from "./mindmapImageConfig";
|
||||||
|
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
|
||||||
// 避免 SSR 阶段触发浏览器依赖,改为运行时动态引入
|
// 避免 SSR 阶段触发浏览器依赖,改为运行时动态引入
|
||||||
// @ts-expect-error 第三方库缺少类型定义
|
// @ts-expect-error 第三方库缺少类型定义
|
||||||
const loadIconModules = async () => {
|
const loadIconModules = async () => {
|
||||||
@@ -60,19 +47,6 @@ type SidebarProps = {
|
|||||||
onClose: () => void;
|
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 = ({
|
const ColorInput = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -139,8 +113,8 @@ const StylePanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
|
|||||||
].forEach((prop) => {
|
].forEach((prop) => {
|
||||||
newStyle[prop] = node.getStyle(prop, false);
|
newStyle[prop] = node.getStyle(prop, false);
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// 异步更新,避免同步 setState 抛 lint
|
||||||
setStyle(newStyle);
|
Promise.resolve().then(() => setStyle(newStyle));
|
||||||
}
|
}
|
||||||
}, [activeNodes]);
|
}, [activeNodes]);
|
||||||
|
|
||||||
@@ -551,7 +525,7 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
|
|||||||
dangerouslySetInnerHTML={{ __html: item.icon }}
|
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>
|
<span className="text-xs text-gray-700">{item.name}</span>
|
||||||
@@ -578,7 +552,7 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
|
|||||||
onClick={() => setSticker(item)}
|
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"
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -623,8 +597,23 @@ const OutlinePanel = ({ mindmap }: { mindmap: any }) => {
|
|||||||
const activate = (node: any) => {
|
const activate = (node: any) => {
|
||||||
const r = mindmap?.renderer;
|
const r = mindmap?.renderer;
|
||||||
if (!node || !r) return;
|
if (!node || !r) return;
|
||||||
r.activeNodeList = [node];
|
// 仅通过已有方法触发激活,避免直接改 renderer 属性
|
||||||
r.lastActiveNodeList = [node];
|
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]);
|
mindmap.emit?.("node_active", node, [node]);
|
||||||
r.setRootNodeCenter?.();
|
r.setRootNodeCenter?.();
|
||||||
};
|
};
|
||||||
@@ -660,45 +649,336 @@ const OutlinePanel = ({ mindmap }: { mindmap: any }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SettingsPanel = ({ mindmap }: { mindmap: any }) => {
|
const SettingsPanel = ({ mindmap }: { mindmap: any }) => {
|
||||||
const [freeDrag, setFreeDrag] = useState<boolean>(!!mindmap?.opt?.enableFreeDrag);
|
const [config, setConfig] = useState({
|
||||||
const [wheel, setWheel] = useState<string>(mindmap?.opt?.mousewheelAction || "zoom");
|
openPerformance: !!mindmap?.opt?.openPerformance,
|
||||||
const [aiOn, setAiOn] = useState<boolean>(true);
|
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) => {
|
useEffect(() => {
|
||||||
setFreeDrag(val);
|
const opt = mindmap?.opt || {};
|
||||||
if (mindmap) mindmap.opt.enableFreeDrag = val;
|
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);
|
const applyWatermark = (next: any) => {
|
||||||
if (mindmap) mindmap.opt.mousewheelAction = val;
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-3">
|
||||||
<Label className="text-xs text-gray-500">自由拖拽</Label>
|
<div className="flex items-center justify-between">
|
||||||
<Toggle size="sm" pressed={freeDrag} onPressedChange={updateFreeDrag}>
|
<Label className="text-xs text-gray-500">显示水印</Label>
|
||||||
{freeDrag ? "开" : "关"}
|
<Toggle
|
||||||
</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>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs text-gray-500">滚轮行为</Label>
|
<div className="space-y-3">
|
||||||
<NativeSelect
|
<div className="flex items-center justify-between">
|
||||||
value={wheel}
|
<Label className="text-xs text-gray-500">性能模式</Label>
|
||||||
onChange={updateWheel}
|
<Toggle
|
||||||
options={[
|
size="sm"
|
||||||
{ label: "缩放", value: "zoom" },
|
pressed={config.openPerformance}
|
||||||
{ label: "平移", value: "move" },
|
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={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={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>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs text-gray-500">AI 功能</Label>
|
<Label className="text-xs text-gray-500">AI 功能</Label>
|
||||||
<Toggle size="sm" pressed={aiOn} onPressedChange={setAiOn}>
|
<Toggle size="sm" pressed={aiOn} onPressedChange={updateAi}>
|
||||||
{aiOn ? "开" : "关"}
|
{aiOn ? "开" : "关"}
|
||||||
</Toggle>
|
</Toggle>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400">其余设置后续接入(滚轮缩放方向、拖拽导入等)。</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -739,11 +1019,13 @@ const NotePanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMa
|
|||||||
// 当选中节点变化时,自动展示首个节点的备注
|
// 当选中节点变化时,自动展示首个节点的备注
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeNodes?.length) {
|
if (!activeNodes?.length) {
|
||||||
setNote("");
|
Promise.resolve().then(() => setNote(""));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const current = readNote(activeNodes[0]);
|
const current = readNote(activeNodes[0]);
|
||||||
setNote(typeof current === "string" ? current : "");
|
Promise.resolve().then(() =>
|
||||||
|
setNote(typeof current === "string" ? current : ""),
|
||||||
|
);
|
||||||
}, [activeNodes]);
|
}, [activeNodes]);
|
||||||
|
|
||||||
const getActiveList = () =>
|
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 [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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-xs text-gray-500">AI 对话</Label>
|
<Label className="text-xs text-gray-500">模式</Label>
|
||||||
<textarea
|
<NativeSelect
|
||||||
className="w-full rounded-md border border-gray-200 p-2 text-sm"
|
value={mode}
|
||||||
rows={4}
|
onChange={(v) => setMode(v as AiMode)}
|
||||||
value={prompt}
|
options={[
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
{ label: "整图生成", value: "full" },
|
||||||
placeholder="输入需求,后续将调用 AI 生成/优化节点"
|
{ label: "对话", value: "chat" },
|
||||||
|
{ label: "选中节点续写", value: "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 className="space-y-2">
|
||||||
发送
|
<Label className="text-xs text-gray-500">模型</Label>
|
||||||
</button>
|
<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={5}
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
mode === "full"
|
||||||
|
? "例如:帮我生成一份 OKR 思维导图"
|
||||||
|
: mode === "partial"
|
||||||
|
? "描述要续写的方向或要补充的要点"
|
||||||
|
: "输入你想询问的内容"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
|
|
||||||
|
|
||||||
export const MindmapSidebar = ({ mindmap, activeNodes, activeTab, onClose }: SidebarProps) => {
|
export const MindmapSidebar = ({ mindmap, activeNodes, activeTab, onClose }: SidebarProps) => {
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
switch (activeTab as SidebarPanel | null) {
|
switch (activeTab as SidebarPanel | null) {
|
||||||
@@ -838,7 +1496,7 @@ export const MindmapSidebar = ({ mindmap, activeNodes, activeTab, onClose }: Sid
|
|||||||
case "note":
|
case "note":
|
||||||
return <NotePanel mindmap={mindmap} activeNodes={activeNodes} />;
|
return <NotePanel mindmap={mindmap} activeNodes={activeNodes} />;
|
||||||
case "ai":
|
case "ai":
|
||||||
return <AiPanel />;
|
return <AiPanel mindmap={mindmap} activeNodes={activeNodes} />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
type ToolbarProps = {
|
type ToolbarProps = {
|
||||||
canBack: boolean;
|
canBack: boolean;
|
||||||
canForward: boolean;
|
canForward: boolean;
|
||||||
activeCount: number;
|
|
||||||
painterMode: boolean;
|
painterMode: boolean;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
@@ -35,8 +34,14 @@ type ToolbarProps = {
|
|||||||
onNew: () => void;
|
onNew: () => void;
|
||||||
onOpenDirectory: () => void;
|
onOpenDirectory: () => void;
|
||||||
onSaveAs: () => void;
|
onSaveAs: () => void;
|
||||||
|
onDeleteMindmap: () => void;
|
||||||
onExportJson: () => void;
|
onExportJson: () => void;
|
||||||
onExportPng: () => void;
|
onExportPng: () => void;
|
||||||
|
onExportSvg: () => void;
|
||||||
|
onExportPdf: () => void;
|
||||||
|
onExportMd: () => void;
|
||||||
|
onExportTxt: () => void;
|
||||||
|
onExportXmind: () => void;
|
||||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,12 +82,9 @@ const ToolbarButton = ({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
const Divider = () => <div className="mx-1 h-8 w-px bg-gray-200" />;
|
|
||||||
|
|
||||||
export const MindmapToolbar = ({
|
export const MindmapToolbar = ({
|
||||||
canBack,
|
canBack,
|
||||||
canForward,
|
canForward,
|
||||||
activeCount,
|
|
||||||
painterMode,
|
painterMode,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
@@ -106,10 +108,18 @@ export const MindmapToolbar = ({
|
|||||||
onNew,
|
onNew,
|
||||||
onOpenDirectory,
|
onOpenDirectory,
|
||||||
onSaveAs,
|
onSaveAs,
|
||||||
|
onDeleteMindmap,
|
||||||
onExportJson,
|
onExportJson,
|
||||||
onExportPng,
|
onExportPng,
|
||||||
|
onExportSvg,
|
||||||
|
onExportPdf,
|
||||||
|
onExportMd,
|
||||||
|
onExportTxt,
|
||||||
|
onExportXmind,
|
||||||
fileInputRef,
|
fileInputRef,
|
||||||
}: ToolbarProps) => {
|
}: ToolbarProps) => {
|
||||||
|
const [showExport, setShowExport] = React.useState(false);
|
||||||
|
|
||||||
const nodeHandlers: Record<NodeToolbarKey, () => void> = {
|
const nodeHandlers: Record<NodeToolbarKey, () => void> = {
|
||||||
back: onUndo,
|
back: onUndo,
|
||||||
forward: onRedo,
|
forward: onRedo,
|
||||||
@@ -137,8 +147,8 @@ export const MindmapToolbar = ({
|
|||||||
openFile: () => fileInputRef.current?.click(),
|
openFile: () => fileInputRef.current?.click(),
|
||||||
import: () => fileInputRef.current?.click(),
|
import: () => fileInputRef.current?.click(),
|
||||||
saveAs: onSaveAs,
|
saveAs: onSaveAs,
|
||||||
exportJson: onExportJson,
|
deleteFile: onDeleteMindmap,
|
||||||
exportPng: onExportPng,
|
exportMenu: () => setShowExport((v) => !v),
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNodeDisabled = (key: NodeToolbarKey) => {
|
const getNodeDisabled = (key: NodeToolbarKey) => {
|
||||||
@@ -184,10 +194,36 @@ export const MindmapToolbar = ({
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".json,application/json"
|
accept=".json,.smm,.xmind,.md,.markdown,application/json"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={onImport}
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export type FileToolbarKey =
|
|||||||
| "openFile"
|
| "openFile"
|
||||||
| "import"
|
| "import"
|
||||||
| "saveAs"
|
| "saveAs"
|
||||||
| "exportJson"
|
| "deleteFile"
|
||||||
| "exportPng";
|
| "exportMenu";
|
||||||
|
|
||||||
type ToolbarMeta = {
|
type ToolbarMeta = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -85,8 +85,8 @@ export const fileToolbarOrder: FileToolbarKey[] = [
|
|||||||
"openFile",
|
"openFile",
|
||||||
"import",
|
"import",
|
||||||
"saveAs",
|
"saveAs",
|
||||||
"exportJson",
|
"deleteFile",
|
||||||
"exportPng",
|
"exportMenu",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const fileToolbarMeta: FileToolbarConfig = {
|
export const fileToolbarMeta: FileToolbarConfig = {
|
||||||
@@ -95,6 +95,6 @@ export const fileToolbarMeta: FileToolbarConfig = {
|
|||||||
openFile: { label: "打开", iconClass: "icondakai" },
|
openFile: { label: "打开", iconClass: "icondakai" },
|
||||||
import: { label: "导入", iconClass: "icondaoru" },
|
import: { label: "导入", iconClass: "icondaoru" },
|
||||||
saveAs: { label: "另存为", iconClass: "iconlingcunwei" },
|
saveAs: { label: "另存为", iconClass: "iconlingcunwei" },
|
||||||
exportJson: { label: "导出 JSON", iconClass: "iconexport" },
|
deleteFile: { label: "删除", iconClass: "iconshanchu" },
|
||||||
exportPng: { label: "导出 PNG", iconClass: "iconPNG" },
|
exportMenu: { label: "导出", iconClass: "iconexport" },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { CustomBlockSchema } from "../schema";
|
import type { CustomBlockSchema } from "../schema";
|
||||||
import { deleteOnlineTable } from "@/lib/online-table";
|
import { deleteOnlineTable } from "@/lib/online-table";
|
||||||
|
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
|
||||||
|
|
||||||
type InlineNode = { text?: unknown };
|
type InlineNode = { text?: unknown };
|
||||||
type TableMenuBlock = Parameters<
|
type TableMenuBlock = Parameters<
|
||||||
@@ -62,13 +63,16 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ documentId: pageId }),
|
body: JSON.stringify({ documentId: pageId }),
|
||||||
});
|
});
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
emitDocumentsChanged(pageId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
editor.removeBlocks([block.id]);
|
editor.removeBlocks([block.id]);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}, [block, editor, router]);
|
}, [block, editor, router]);
|
||||||
|
|
||||||
const handleDeleteBlock = useCallback(() => {
|
const handleDeleteBlock = useCallback(async () => {
|
||||||
if (block.type === "pageReference") {
|
if (block.type === "pageReference") {
|
||||||
void removePageReference();
|
void removePageReference();
|
||||||
return;
|
return;
|
||||||
@@ -80,11 +84,43 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
|
|||||||
.catch((error) => console.error("删除在线表格失败", error));
|
.catch((error) => console.error("删除在线表格失败", error));
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } }));
|
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } }));
|
||||||
|
emitAssetsChanged(currentDocumentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
editor.removeBlocks([block.id]);
|
||||||
|
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]);
|
editor.removeBlocks([block.id]);
|
||||||
}, [block, editor, removePageReference]);
|
}, [block, currentDocumentId, editor, removePageReference]);
|
||||||
|
|
||||||
const turnToPage = useCallback(async () => {
|
const turnToPage = useCallback(async () => {
|
||||||
const response = await fetch("/api/documents/create-child", {
|
const response = await fetch("/api/documents/create-child", {
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
type: "mindmap",
|
type: "mindmap",
|
||||||
|
props: { docId: currentDocumentId },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
referenceBlock,
|
referenceBlock,
|
||||||
|
|||||||
@@ -14,13 +14,10 @@ import { mediaBlock } from "./blocks/MediaBlock";
|
|||||||
import { onlineTableBlock } from "./blocks/OnlineTableBlock";
|
import { onlineTableBlock } from "./blocks/OnlineTableBlock";
|
||||||
import { mindmapBlock } from "./blocks/MindmapBlock";
|
import { mindmapBlock } from "./blocks/MindmapBlock";
|
||||||
|
|
||||||
const headingSpec =
|
const headingSpec = createHeadingBlockSpec({
|
||||||
typeof window === "undefined"
|
levels: [1, 2, 3, 4, 5],
|
||||||
? defaultBlockSpecs.heading
|
allowToggleHeadings: true,
|
||||||
: createHeadingBlockSpec({
|
});
|
||||||
levels: [1, 2, 3, 4, 5],
|
|
||||||
allowToggleHeadings: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const customBlockSchema = BlockNoteSchema.create({
|
export const customBlockSchema = BlockNoteSchema.create({
|
||||||
blockSpecs: {
|
blockSpecs: {
|
||||||
@@ -31,7 +28,7 @@ export const customBlockSchema = BlockNoteSchema.create({
|
|||||||
progressMeter: progressBlock,
|
progressMeter: progressBlock,
|
||||||
media: mediaBlock,
|
media: mediaBlock,
|
||||||
onlineTable: onlineTableBlock(),
|
onlineTable: onlineTableBlock(),
|
||||||
mindmap: mindmapBlock,
|
mindmap: mindmapBlock(),
|
||||||
},
|
},
|
||||||
inlineContentSpecs: defaultInlineContentSpecs,
|
inlineContentSpecs: defaultInlineContentSpecs,
|
||||||
styleSpecs: defaultStyleSpecs,
|
styleSpecs: defaultStyleSpecs,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { MediaAsset, MediaKind, MediaSelection } from "@/types/media";
|
import type { MediaAsset, MediaKind, MediaSelection } from "@/types/media";
|
||||||
|
import { emitAssetsChanged } from "@/lib/events";
|
||||||
|
|
||||||
export type PickerTab = "upload" | "recent" | "link" | "icon";
|
export type PickerTab = "upload" | "recent" | "link" | "icon";
|
||||||
|
|
||||||
@@ -165,6 +166,7 @@ export function ImagePickerDialog({
|
|||||||
if (!payload.asset?.file_url) {
|
if (!payload.asset?.file_url) {
|
||||||
throw new Error("返回数据缺少文件地址");
|
throw new Error("返回数据缺少文件地址");
|
||||||
}
|
}
|
||||||
|
emitAssetsChanged(documentId, payload.asset);
|
||||||
onSelect({
|
onSelect({
|
||||||
assetId: payload.asset.id,
|
assetId: payload.asset.id,
|
||||||
fileUrl: payload.asset.file_url,
|
fileUrl: payload.asset.file_url,
|
||||||
@@ -224,6 +226,7 @@ export function ImagePickerDialog({
|
|||||||
if (!payload.asset?.file_url) {
|
if (!payload.asset?.file_url) {
|
||||||
throw new Error("缺少图片地址");
|
throw new Error("缺少图片地址");
|
||||||
}
|
}
|
||||||
|
emitAssetsChanged(documentId, payload.asset);
|
||||||
onSelect({
|
onSelect({
|
||||||
assetId: payload.asset.id,
|
assetId: payload.asset.id,
|
||||||
fileUrl: payload.asset.file_url,
|
fileUrl: payload.asset.file_url,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface FileTreeProps {
|
|||||||
disableSelection?: boolean;
|
disableSelection?: boolean;
|
||||||
onDropFiles?: (docId: string, files: FileList) => void;
|
onDropFiles?: (docId: string, files: FileList) => void;
|
||||||
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
|
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
|
||||||
|
onAssetClick?: (asset: MediaAsset, event: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INDENT = 16;
|
const INDENT = 16;
|
||||||
@@ -42,6 +43,7 @@ export function FileTree({
|
|||||||
disableSelection = false,
|
disableSelection = false,
|
||||||
onDropFiles,
|
onDropFiles,
|
||||||
onAssetContextMenu,
|
onAssetContextMenu,
|
||||||
|
onAssetClick,
|
||||||
}: FileTreeProps) {
|
}: FileTreeProps) {
|
||||||
if (nodes.length === 0) {
|
if (nodes.length === 0) {
|
||||||
return <div className="px-4 py-6 text-sm text-gray-400">暂无页面,点击下方按钮创建</div>;
|
return <div className="px-4 py-6 text-sm text-gray-400">暂无页面,点击下方按钮创建</div>;
|
||||||
@@ -83,6 +85,7 @@ export function FileTree({
|
|||||||
disableSelection={disableSelection}
|
disableSelection={disableSelection}
|
||||||
onDropFiles={onDropFiles}
|
onDropFiles={onDropFiles}
|
||||||
onAssetContextMenu={onAssetContextMenu}
|
onAssetContextMenu={onAssetContextMenu}
|
||||||
|
onAssetClick={onAssetClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -106,6 +109,7 @@ interface FileTreeNodeProps {
|
|||||||
disableSelection: boolean;
|
disableSelection: boolean;
|
||||||
onDropFiles?: (docId: string, files: FileList) => void;
|
onDropFiles?: (docId: string, files: FileList) => void;
|
||||||
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
|
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
|
||||||
|
onAssetClick?: (asset: MediaAsset, event: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileTreeNode({
|
function FileTreeNode({
|
||||||
@@ -125,6 +129,7 @@ function FileTreeNode({
|
|||||||
disableSelection,
|
disableSelection,
|
||||||
onDropFiles,
|
onDropFiles,
|
||||||
onAssetContextMenu,
|
onAssetContextMenu,
|
||||||
|
onAssetClick,
|
||||||
}: FileTreeNodeProps) {
|
}: FileTreeNodeProps) {
|
||||||
const isExpanded = expanded.has(node.id);
|
const isExpanded = expanded.has(node.id);
|
||||||
const assets = useMemo(() => assetsByDoc[node.id] ?? [], [assetsByDoc, node.id]);
|
const assets = useMemo(() => assetsByDoc[node.id] ?? [], [assetsByDoc, node.id]);
|
||||||
@@ -202,13 +207,23 @@ function FileTreeNode({
|
|||||||
onToggleAssetSelect ? () => onToggleAssetSelect(asset.id) : undefined
|
onToggleAssetSelect ? () => onToggleAssetSelect(asset.id) : undefined
|
||||||
}
|
}
|
||||||
onSelectOnly={onSelectOnlyAsset ? () => onSelectOnlyAsset(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
|
stopBubble
|
||||||
onContextMenu={
|
onContextMenu={
|
||||||
onAssetContextMenu
|
onAssetContextMenu
|
||||||
? (e) => {
|
? (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (onAssetClick) {
|
||||||
|
onAssetClick(asset, e);
|
||||||
|
}
|
||||||
onAssetContextMenu(e, asset);
|
onAssetContextMenu(e, asset);
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
@@ -264,17 +279,23 @@ function FileLeafRow({
|
|||||||
onSelectOnly?: () => void;
|
onSelectOnly?: () => void;
|
||||||
stopBubble?: boolean;
|
stopBubble?: boolean;
|
||||||
onContextMenu?: (event: React.MouseEvent) => void;
|
onContextMenu?: (event: React.MouseEvent) => void;
|
||||||
|
onDoubleClick?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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 }}
|
style={{ paddingLeft: depth * INDENT + 32 }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (stopBubble) e.stopPropagation();
|
if (stopBubble) e.stopPropagation();
|
||||||
// 附件行默认只负责选中,不直接打开,避免误触下载
|
|
||||||
if (selectable) return;
|
|
||||||
onClick();
|
onClick();
|
||||||
}}
|
}}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
if (stopBubble) e.stopPropagation();
|
||||||
|
onDoubleClick?.();
|
||||||
|
}}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (stopBubble) e.stopPropagation();
|
if (stopBubble) e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ import { supabaseBrowser } from "@/lib/supabase/client";
|
|||||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||||
import { FileTree } from "@/components/sidebar/file-tree";
|
import { FileTree } from "@/components/sidebar/file-tree";
|
||||||
import type { MediaAsset } from "@/types/media";
|
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 = [
|
const TOP_BUTTONS = [
|
||||||
{ id: "search", icon: SearchIcon, label: "搜索" },
|
{ id: "search", icon: SearchIcon, label: "搜索" },
|
||||||
@@ -93,6 +95,13 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
const [trashOpen, setTrashOpen] = useState(false);
|
const [trashOpen, setTrashOpen] = useState(false);
|
||||||
const [trashSearch, setTrashSearch] = useState("");
|
const [trashSearch, setTrashSearch] = useState("");
|
||||||
const [emptyingTrash, setEmptyingTrash] = useState(false);
|
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);
|
const workspaceMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -104,10 +113,43 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
});
|
});
|
||||||
}, [sidebarData.documents]);
|
}, [sidebarData.documents]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMediaAssets(sidebarData.mediaAssets ?? []);
|
||||||
|
}, [sidebarData.mediaAssets]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMindmapDocs(sidebarData.mindmapDocs ?? []);
|
||||||
|
}, [sidebarData.mindmapDocs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedAssetIds(new Set());
|
||||||
|
setLastSelectedAssetId(null);
|
||||||
|
}, [mediaAssets, sidebarData.documents]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [activeId, setOpen]);
|
}, [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 () => {
|
const refreshTree = useCallback(async () => {
|
||||||
await sidebarQuery.refetch();
|
await sidebarQuery.refetch();
|
||||||
}, [sidebarQuery]);
|
}, [sidebarQuery]);
|
||||||
@@ -128,6 +170,27 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
};
|
};
|
||||||
}, [refreshTree]);
|
}, [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 sections = useMemo(() => buildSidebarSectionsFromTree(tree), [tree]);
|
||||||
const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]);
|
const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]);
|
||||||
const publicNodes = useMemo(() => sections.find((section) => section.id === "public")?.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 assetsByDoc = useMemo(() => {
|
||||||
const map: Record<string, MediaAsset[]> = {};
|
const map: Record<string, MediaAsset[]> = {};
|
||||||
const assets = sidebarData.mediaAssets ?? [];
|
const assets = mediaAssets;
|
||||||
|
|
||||||
assets.forEach((asset) => {
|
assets.forEach((asset) => {
|
||||||
if (!map[asset.document_id]) {
|
if (!map[asset.document_id]) {
|
||||||
map[asset.document_id] = [];
|
map[asset.document_id] = [];
|
||||||
}
|
}
|
||||||
map[asset.document_id].push(asset);
|
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;
|
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 =
|
const activeWorkspace =
|
||||||
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
|
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
|
||||||
@@ -170,6 +285,8 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
|
|
||||||
const handleOpenDocument = useCallback(
|
const handleOpenDocument = useCallback(
|
||||||
(documentId: string, mode: "main" | "sidebar") => {
|
(documentId: string, mode: "main" | "sidebar") => {
|
||||||
|
setSelectedAssetIds(new Set());
|
||||||
|
setLastSelectedAssetId(null);
|
||||||
const targetPath = `/documents/${documentId}`;
|
const targetPath = `/documents/${documentId}`;
|
||||||
if (mode === "main") {
|
if (mode === "main") {
|
||||||
router.push(targetPath);
|
router.push(targetPath);
|
||||||
@@ -248,6 +365,11 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOpenAsset = useCallback((asset: MediaAsset) => {
|
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;
|
const url = asset.signed_url ?? asset.file_url;
|
||||||
if (!url) {
|
if (!url) {
|
||||||
window.alert("暂无可用的文件链接");
|
window.alert("暂无可用的文件链接");
|
||||||
@@ -256,8 +378,213 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
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(
|
const handleResizeStart = useCallback(
|
||||||
(event: React.MouseEvent) => {
|
(event: React.MouseEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -389,8 +716,12 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
body: JSON.stringify({ documentId }),
|
body: JSON.stringify({ documentId }),
|
||||||
});
|
});
|
||||||
await refreshTree();
|
await refreshTree();
|
||||||
|
emitDocumentsChanged(documentId);
|
||||||
|
if (activeId === documentId) {
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[refreshTree],
|
[activeId, refreshTree, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConvertToChild = useCallback(
|
const handleConvertToChild = useCallback(
|
||||||
@@ -713,6 +1044,11 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
onOpenAsset={handleOpenAsset}
|
onOpenAsset={handleOpenAsset}
|
||||||
onCreateChild={handleCreate}
|
onCreateChild={handleCreate}
|
||||||
onContextMenu={openContextMenu}
|
onContextMenu={openContextMenu}
|
||||||
|
onAssetContextMenu={handleAssetContextMenu}
|
||||||
|
selectedAssetIds={selectedAssetIds}
|
||||||
|
onAssetClick={(asset, e) => handleSelectAsset(asset, e)}
|
||||||
|
onToggleAssetSelect={toggleAssetCheckbox}
|
||||||
|
onSelectOnlyAsset={selectOnlyAsset}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -772,6 +1108,25 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
onDelete={() => void handleDelete(contextMenu.node.id)}
|
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}>
|
<Drawer open={trashOpen} onOpenChange={setTrashOpen}>
|
||||||
<DrawerContent className="max-h-[90vh]">
|
<DrawerContent className="max-h-[90vh]">
|
||||||
<DrawerHeader className="text-left">
|
<DrawerHeader className="text-left">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { DocumentRecord } from "@/lib/documents";
|
import type { DocumentRecord } from "@/lib/documents";
|
||||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||||
|
import type { MediaAsset } from "@/types/media";
|
||||||
|
|
||||||
export type SidebarSectionId = "starred" | "public" | "shared" | "private" | "templates";
|
export type SidebarSectionId = "starred" | "public" | "shared" | "private" | "templates";
|
||||||
|
|
||||||
@@ -16,4 +17,12 @@ export interface SidebarInitialData {
|
|||||||
workspaces: WorkspaceSummary[];
|
workspaces: WorkspaceSummary[];
|
||||||
documents: DocumentRecord[];
|
documents: DocumentRecord[];
|
||||||
trashedDocuments: TrashRecord[];
|
trashedDocuments: TrashRecord[];
|
||||||
|
/**
|
||||||
|
* 已存在思维导图文件(本地或 supabase)对应的页面 id 列表
|
||||||
|
*/
|
||||||
|
mindmapDocs?: string[];
|
||||||
|
/**
|
||||||
|
* 可选的媒体资源列表(旧字段向后兼容)
|
||||||
|
*/
|
||||||
|
mediaAssets?: MediaAsset[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } }));
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -1,40 +1,31 @@
|
|||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
import type { DocumentRecord, DocumentNode } from "@/lib/documents";
|
import type { DocumentRecord, DocumentNode } from "@/lib/documents";
|
||||||
import type { SidebarSectionId, TrashRecord } from "@/components/sidebar/types";
|
import type { SidebarSectionId, TrashRecord } from "@/components/sidebar/types";
|
||||||
import type { Database } from "@/types/supabase";
|
import type { Database } from "@/types/supabase";
|
||||||
|
import type { MediaAsset } from "@/types/media";
|
||||||
import { buildDocumentTree } from "@/lib/documents";
|
import { buildDocumentTree } from "@/lib/documents";
|
||||||
|
|
||||||
type TypedClient = SupabaseClient<Database>;
|
type TypedClient = SupabaseClient<Database>;
|
||||||
|
|
||||||
export interface SidebarSectionSnapshot {
|
export interface SidebarDataset {
|
||||||
id: SidebarSectionId;
|
documents: DocumentRecord[];
|
||||||
title: string;
|
trashedDocuments: TrashRecord[];
|
||||||
icon?: string;
|
/**
|
||||||
nodes: DocumentNode[];
|
* 仅依据远端 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(
|
export async function fetchSidebarDataset(
|
||||||
client: TypedClient,
|
client: TypedClient,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<{ documents: DocumentRecord[]; trashedDocuments: TrashRecord[] }> {
|
): Promise<SidebarDataset> {
|
||||||
const { data: documentRows, error } = await client
|
const { data: documentRows, error } = await client
|
||||||
.from("documents")
|
.from("documents")
|
||||||
.select(
|
.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)
|
.eq("workspace_id", workspaceId)
|
||||||
.is("deleted_at", null)
|
.is("deleted_at", null)
|
||||||
@@ -78,9 +69,26 @@ export async function fetchSidebarDataset(
|
|||||||
access_scope: (row.access_scope ?? "private") as DocumentRecord["access_scope"],
|
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 {
|
return {
|
||||||
documents,
|
documents,
|
||||||
trashedDocuments,
|
trashedDocuments,
|
||||||
|
mindmapDocs,
|
||||||
|
mediaAssets,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,8 +115,8 @@ export function flattenDocumentTree(
|
|||||||
expanded: Set<string>,
|
expanded: Set<string>,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
parentId: string | null = null,
|
parentId: string | null = null,
|
||||||
): FlattenedTreeNode[] {
|
): Array<{ node: DocumentNode; depth: number; parentId: string | null }> {
|
||||||
const list: FlattenedTreeNode[] = [];
|
const list: Array<{ node: DocumentNode; depth: number; parentId: string | null }> = [];
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
list.push({ node, depth, parentId });
|
list.push({ node, depth, parentId });
|
||||||
if (node.children.length > 0 && expanded.has(node.id)) {
|
if (node.children.length > 0 && expanded.has(node.id)) {
|
||||||
@@ -118,12 +126,29 @@ export function flattenDocumentTree(
|
|||||||
return list;
|
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[] {
|
export function buildSidebarSections(records: DocumentRecord[]): SidebarSectionSnapshot[] {
|
||||||
const tree = buildDocumentTree(records);
|
const tree = buildDocumentTree(records);
|
||||||
return buildSidebarSectionsFromTree(tree);
|
return buildSidebarSectionsFromTree(tree);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSidebarSectionsFromTree(tree: DocumentNode[]): SidebarSectionSnapshot[] {
|
export function buildSidebarSectionsFromTree(
|
||||||
|
tree: DocumentNode[],
|
||||||
|
): SidebarSectionSnapshot[] {
|
||||||
const sections: Array<{ id: SidebarSectionId; predicate: NodePredicate }> = [
|
const sections: Array<{ id: SidebarSectionId; predicate: NodePredicate }> = [
|
||||||
{ id: "starred", predicate: (node) => Boolean(node.is_starred) },
|
{ id: "starred", predicate: (node) => Boolean(node.is_starred) },
|
||||||
{ id: "public", predicate: (node) => node.access_scope === "public" },
|
{ 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 |
Reference in New Issue
Block a user