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