0.1.13 AI功能大改

This commit is contained in:
liaibo
2026-01-10 23:08:56 +08:00
parent 63050f3927
commit be71849aa5
37 changed files with 6400 additions and 160 deletions
@@ -26,6 +26,7 @@ import { MindmapSidebarTrigger } from "./MindmapSidebarTrigger";
import { MindmapNavigator } from "./MindmapNavigator";
import { MindmapMiniMap } from "./MindmapMiniMap";
import { MindmapCount } from "./MindmapCount";
import { MindmapContextMenu } from "./MindmapContextMenu";
import type { SidebarPanel } from "./mindmapSidebarConfig";
import type { MindMapNode } from "./mindmapTypes";
import { Label } from "@/components/ui/label";
@@ -51,6 +52,10 @@ const getImageSizeSafe = (url: string): Promise<{ width: number; height: number
img.src = url;
});
// 类型检查辅助函数
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
type MindMapInstance = {
execCommand: (command: string, ...args: unknown[]) => void;
destroy: () => void;
@@ -132,7 +137,7 @@ const normalizeMindmapData = (input: unknown): unknown => {
return input;
};
// 持久化/初始化统一使用根节点对象作为数据载体,避免把包含额外字段的 wrapper 误传给 simple-mind-map
// 持久化/初始化统一使用"根节点对象"作为数据载体,避免把包含额外字段的 wrapper 误传给 simple-mind-map
// 从而触发 RichText 对 wrapper.data 的处理(wrapper.data.text 可能不存在 → htmlEscape 崩溃)。
const canonicalizeMindmapData = (input: unknown): MindMapData => {
const normalized = (normalizeMindmapData(input) ?? defaultMindmapData) as any;
@@ -142,6 +147,82 @@ const canonicalizeMindmapData = (input: unknown): MindMapData => {
return (normalizeMindmapData(root) ?? defaultMindmapData) as MindMapData;
};
// 将思维导图数据中的 asset:id 格式转换为实际的签名 URL
// 返回转换后的数据和 signed URL -> asset ID 的映射
const resolveAssetUrls = async (data: MindMapData): Promise<{ data: MindMapData; urlToAssetId: Map<string, string> }> => {
const assetIds: string[] = [];
// 递归收集所有 asset:id
const collectAssetIds = (node: any) => {
if (!node) return;
if (node.image?.url?.startsWith?.("asset:")) {
assetIds.push(node.image.url.replace("asset:", ""));
}
if (Array.isArray(node.children)) {
node.children.forEach(collectAssetIds);
}
};
collectAssetIds(data);
// 如果没有 asset:id,直接返回原数据和空映射
if (assetIds.length === 0) return { data, urlToAssetId: new Map() };
// 批量获取签名 URL
const urlMap = new Map<string, string>();
await Promise.all(assetIds.map(async (id) => {
try {
const response = await fetch(`/api/media/sign?assetId=${id}`);
if (response.ok) {
const result = await response.json();
urlMap.set(id, result.signedUrl);
}
} catch (e) {
console.error(`获取 asset ${id} 签名 URL 失败`, e);
}
}));
// 递归替换 asset:id 为签名 URL,并建立反向映射
const urlToAssetId = new Map<string, string>();
const replaceAssetIds = (node: any): any => {
if (!node) return node;
const newNode = { ...node };
if (newNode.image?.url?.startsWith?.("asset:")) {
const id = newNode.image.url.replace("asset:", "");
const signedUrl = urlMap.get(id);
if (signedUrl) {
newNode.image = { ...newNode.image, url: signedUrl };
// 建立反向映射:signedUrl -> asset:id
urlToAssetId.set(signedUrl, id);
}
}
if (Array.isArray(newNode.children)) {
newNode.children = newNode.children.map(replaceAssetIds);
}
return newNode;
};
return { data: replaceAssetIds(data), urlToAssetId };
};
// 将思维导图数据中的签名 URL 转换回 asset:id 格式(用于保存)
const revertToAssetIds = (data: MindMapData, urlToAssetId: Map<string, string>): MindMapData => {
const revertNode = (node: any): any => {
if (!node) return node;
const newNode = { ...node };
if (newNode.image?.url && typeof newNode.image.url === "string") {
const assetId = urlToAssetId.get(newNode.image.url);
if (assetId) {
newNode.image = { ...newNode.image, url: `asset:${assetId}` };
}
}
if (Array.isArray(newNode.children)) {
newNode.children = newNode.children.map(revertNode);
}
return newNode;
};
return revertNode(data);
};
const STORAGE_PREFIX = "wolai-mindmap-autosave-";
function downloadJson(data: unknown, name: string) {
@@ -329,7 +410,7 @@ const MindmapBlockView = ({
const hotkeyScopeRef = useRef(false);
const lastInteractionAtRef = useRef(0);
const skipNextPasteRef = useRef(false);
const persistDataRef = useRef<((data: unknown) => void) | null>(null);
const persistDataRef = useRef<((data: unknown) => void) | null>(null);
const recentNodeDblclickRef = useRef(false);
const deletingRef = useRef(false);
const textEditOpenRef = useRef(false);
@@ -337,6 +418,12 @@ const MindmapBlockView = ({
const lastTextEditRefocusAtRef = useRef(0);
const pendingInitActivateRootRef = useRef(false);
const pendingRenderEndActivateRootRef = useRef(false);
// 存储 signed URL 到 asset:id 的映射,用于保存时转换回 asset:id 格式
const signedUrlToAssetIdRef = useRef(new Map<string, string>());
// 存储当前思维导图中使用的所有图片 URL,用于检测图片删除
const currentImageUrlsRef = useRef(new Set<string>());
// 存储已删除的 asset ID,用于撤销时恢复
const deletedAssetIdsRef = useRef(new Set<string>());
const getRootNode = useCallback((mm?: MindMapInstance | null) => {
const instance = mm ?? mindmap;
@@ -353,6 +440,27 @@ const MindmapBlockView = ({
);
const mindmapId = block.id;
// 获取 workspaceId(用于上传图片)
useEffect(() => {
let cancelled = false;
if (!docId) return;
void (async () => {
try {
const res = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(docId)}`);
const json: unknown = await res.json().catch(() => null);
if (!res.ok) return;
if (cancelled) return;
const obj = isRecord(json) ? json : {};
setWorkspaceId(String(obj.workspaceId ?? ""));
} catch {
// ignore
}
})();
return () => {
cancelled = true;
};
}, [docId]);
useEffect(() => {
hasLocalEditsRef.current = false;
applyingRemoteRef.current = false;
@@ -1089,13 +1197,17 @@ const MindmapBlockView = ({
(data: unknown) => {
if (!editor) return;
// 关键:全屏/非全屏切换会重建 mindmap 实例,初始化数据必须跟随最新编辑
// 否则切换视图时会用旧数据,表现为看起来没保存
const safe = canonicalizeMindmapData(data);
// 否则切换视图时会用旧数据,表现为"看起来没保存"
let safe = canonicalizeMindmapData(data);
// 将 signed URL 转换回 asset:id 格式用于保存
if (signedUrlToAssetIdRef.current.size > 0) {
safe = revertToAssetIds(safe, signedUrlToAssetIdRef.current);
}
initialDataRef.current = safe;
window.localStorage.setItem(autosaveKey, JSON.stringify(safe));
window.localStorage.setItem(autosaveKey, JSON.stringify(safe));
// 注意:全屏(Portal 覆盖层)下如果调用 updateBlockBlockNote/ProseMirror
// 可能会在防抖保存时(例如 ~800ms)抢回焦点,导致节点双击编辑的输入框
// 被迫退出(表现为有光标但过一会儿就消失)。
// 被迫退出(表现为"有光标但过一会儿就消失")。
// 因此全屏态只做 localStorage + 后端同步,等退出全屏(实例重建/卸载)
// 时再统一把最新数据写回 block。
if (!effectiveFullscreen) {
@@ -1418,8 +1530,12 @@ const MindmapBlockView = ({
dataForInitSource = block.props.data ? "block.props.data" : "initialDataRef";
return block.props.data ?? initialDataRef.current ?? defaultMindmapData;
})();
const dataForInit = canonicalizeMindmapData(rawForInit);
initialDataRef.current = dataForInit;
const dataForInitCanonical = canonicalizeMindmapData(rawForInit);
initialDataRef.current = dataForInitCanonical;
// 将 asset:id 转换为签名 URL,并获取映射
const { data: dataForInit, urlToAssetId } = await resolveAssetUrls(dataForInitCanonical);
// 保存映射供后续保存时使用
signedUrlToAssetIdRef.current = urlToAssetId;
type MindMapConstructor = new (options: {
el: HTMLElement;
@@ -1439,13 +1555,15 @@ const MindmapBlockView = ({
const MindMapCtor = MindMap as unknown as MindMapConstructor;
// Portal 全屏时,simple-mind-map 的节点文本编辑框会 append 到 body,并受 z-index 影响:
// 若 z-index 低于我们的全屏覆盖层,会出现进入编辑态但看不到独立输入框/光标的问题。
// 若 z-index 低于我们的全屏覆盖层,会出现"进入编辑态但看不到独立输入框/光标"的问题。
// 这里显式提高节点编辑框 z-index,并把内部浮层挂到 wrapper 上,确保全屏下可见且不会被遮挡。
const fullscreenTextEditOptions = effectiveFullscreen
? {
// 让编辑框仍 append 到 body(库默认行为),只提高 z-index,避免被全屏覆盖层挡住。
// 注意:如果把编辑框 append 到 wrapperoverflow-hidden),可能会影响 box-shadow/可见性。
nodeTextEditZIndex: 100000,
// 将图片调整遮罩挂载到容器内,确保全屏下可见且事件正常
customInnerElsAppendTo: containerRef.current || undefined,
}
: null;
@@ -1950,12 +2068,97 @@ const MindmapBlockView = ({
const [imageHeight, setImageHeight] = useState<number | string>(200);
const [imageTitle, setImageTitle] = useState("");
const [imagePosition, setImagePosition] = useState<string>("top");
const [uploadingImage, setUploadingImage] = useState(false);
const [workspaceId, setWorkspaceId] = useState("");
const fileInputForImage = useRef<HTMLInputElement | null>(null);
// 图片预览(双击节点图片)
const [showImageViewer, setShowImageViewer] = useState(false);
const [viewerSrc, setViewerSrc] = useState("");
const [viewerMeta, setViewerMeta] = useState<{ title?: string; width?: number; height?: number }>({});
const [viewerZoom, setViewerZoom] = useState(1);
// 解析图片 URL,如果是 asset_id 格式则获取签名 URL
const resolveImageUrl = async (urlOrAssetId: string): Promise<string> => {
// 检查是否是 asset_id 格式
if (urlOrAssetId.startsWith("asset:")) {
const assetId = urlOrAssetId.replace("asset:", "");
try {
const response = await fetch(`/api/media/sign?assetId=${assetId}`);
if (response.ok) {
const data = await response.json();
return data.signedUrl;
}
} catch (e) {
console.error("获取签名 URL 失败", e);
}
return urlOrAssetId;
}
// 如果是完整的 URL,直接返回
return urlOrAssetId;
};
// 递归收集思维导图中所有的图片 URL
const collectImageUrls = (data: any): string[] => {
if (!data) return [];
const urls: string[] = [];
const traverse = (node: any) => {
if (!node) return;
if (node.image && typeof node.image === "string" && node.image) {
urls.push(node.image);
}
if (Array.isArray(node.children)) {
node.children.forEach(traverse);
}
};
traverse(data);
return urls;
};
// 处理图片删除(将 asset 移到垃圾桶)
const deleteImageAssets = async (assetIds: string[]) => {
if (!assetIds.length || !docId) return;
try {
console.log("[MindmapBlock] Deleting image assets:", assetIds);
const response = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds }),
});
if (response.ok) {
// 记录到已删除列表
assetIds.forEach(id => deletedAssetIdsRef.current.add(id));
console.log("[MindmapBlock] Image assets deleted successfully, emitting ASSETS_CHANGED_EVENT");
// 通知文件树刷新,传递被删除的 assetIds
emitAssetsChanged(docId, undefined, assetIds);
} else {
const payload = await response.json().catch(() => null);
console.error("[MindmapBlock] Failed to delete image assets:", payload?.error);
}
} catch (e) {
console.error("删除图片资源失败", e);
}
};
// 处理图片恢复(从垃圾桶恢复)
const restoreImageAssets = async (assetIds: string[]) => {
if (!assetIds.length || !docId) return;
try {
const response = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "restore", assetIds }),
});
if (response.ok) {
// 从已删除列表移除
assetIds.forEach(id => deletedAssetIdsRef.current.delete(id));
// 通知文件树刷新,传递恢复的 assetIds(空列表表示需要重新获取)
emitAssetsChanged(docId);
}
} catch (e) {
console.error("恢复图片资源失败", e);
}
};
const imgToolbarRef = useRef<HTMLDivElement | null>(null);
const [imgToolbarState, setImgToolbarState] = useState({
show: false,
@@ -1964,20 +2167,35 @@ const MindmapBlockView = ({
placement: "top" as "top" | "bottom" | "left" | "right",
});
const imgToolbarHover = useRef(false);
const imgNodeRef = useRef<{ node: MindMapNode | null; imgNode: any }>({ node: null, imgNode: null });
const handleImage = () => {
const first = mindmap?.renderer?.activeNodeList?.[0] as MindMapNode | undefined;
if (first?.getStyle) {
const placement = first.getStyle("imgPlacement", false) as string;
const placement = first.getStyle("imgPlacement", false) as string;
if (placement) setImagePosition(placement);
}
setShowImageModal(true);
};
// 图片工具栏更新位置函数
const updateImgToolbarPos = useCallback(() => {
if (!imgToolbarState.show || !imgNodeRef.current.imgNode || !imgToolbarRef.current) return;
const toolbarRect = imgToolbarRef.current.getBoundingClientRect();
const imgRbox = imgNodeRef.current.imgNode?.rbox?.();
if (!imgRbox) return;
const { width: imgWidth, x, y } = imgRbox;
setImgToolbarState((s) => ({
...s,
x: x + imgWidth / 2 - toolbarRect.width / 2,
y: y - toolbarRect.height - 5,
}));
}, [imgToolbarState.show]);
// 预览:监听 mindmap 事件
useEffect(() => {
if (!mindmap) return;
const handler = (node: MindMapNode, e?: Event) => {
const handler = async (node: MindMapNode, e?: Event) => {
e?.stopPropagation?.();
e?.preventDefault?.();
const srcRaw =
@@ -1986,7 +2204,9 @@ const MindmapBlockView = ({
node?.data?.image;
const src = typeof srcRaw === "string" ? srcRaw : "";
if (src) {
setViewerSrc(src);
// 解析图片 URL,如果是 asset_id 格式则获取签名 URL
const resolvedUrl = await resolveImageUrl(src);
setViewerSrc(resolvedUrl);
const sizeRaw = node?.getData?.("imageSize");
const size =
sizeRaw && typeof sizeRaw === "object"
@@ -1998,56 +2218,183 @@ const MindmapBlockView = ({
width: Number(size.width) || undefined,
height: Number(size.height) || undefined,
});
setViewerZoom(1); // 重置缩放
setShowImageViewer(true);
}
};
const onActive = () => {
const onActive = (node: MindMapNode) => {
if (node === imgNodeRef.current.node) return;
const list = (mindmap.renderer?.activeNodeList || []) as MindMapNode[];
const has = list.some((n) => !!n?.getData?.("image"));
if (!has) setImgToolbarState((s) => ({ ...s, show: false }));
if (!has) {
setImgToolbarState((s) => ({ ...s, show: false }));
imgNodeRef.current = { node: null, imgNode: null };
}
};
const showToolbarOnClick = (node: MindMapNode, _svgImg: unknown, evt: Event | undefined) => {
const target = evt?.target as Element | undefined;
const bbox = target?.getBoundingClientRect?.();
const showToolbarOnClick = (node: MindMapNode, imgNode: any, _evt: Event | undefined) => {
imgNodeRef.current = { node, imgNode };
const placement = (node?.getStyle?.("imgPlacement") || "top") as "top" | "bottom" | "left" | "right";
if (!bbox) return;
setImagePosition(placement);
setImgToolbarState({
setImgToolbarState((s) => ({
...s,
show: true,
x: bbox.left,
y: bbox.top,
placement,
}));
// 使用 requestAnimationFrame 确保 DOM 更新后再定位
requestAnimationFrame(() => {
updateImgToolbarPos();
});
};
const hideToolbar = () => {
if (!imgToolbarHover.current) {
setImgToolbarState((s) => ({ ...s, show: false }));
imgNodeRef.current = { node: null, imgNode: null };
}
};
mindmap.on?.("node_img_dblclick", handler);
mindmap.on?.("node_active", onActive);
mindmap.on?.("node_img_click", showToolbarOnClick);
mindmap.on?.("draw_click", hideToolbar);
// 添加更多事件监听以正确隐藏/更新工具栏
mindmap.on?.("svg_mousedown", hideToolbar);
mindmap.on?.("node_dblclick", hideToolbar);
mindmap.on?.("scale", updateImgToolbarPos);
mindmap.on?.("translate", hideToolbar);
mindmap.on?.("node_img_adjust_btn_mousedown", hideToolbar);
mindmap.on?.("delete_node_img_from_delete_btn", hideToolbar);
return () => {
mindmap.off?.("node_img_dblclick", handler);
mindmap.off?.("node_active", onActive);
mindmap.off?.("node_img_click", showToolbarOnClick);
mindmap.off?.("draw_click", hideToolbar);
mindmap.off?.("svg_mousedown", hideToolbar);
mindmap.off?.("node_dblclick", hideToolbar);
mindmap.off?.("scale", updateImgToolbarPos);
mindmap.off?.("translate", hideToolbar);
mindmap.off?.("node_img_adjust_btn_mousedown", hideToolbar);
mindmap.off?.("delete_node_img_from_delete_btn", hideToolbar);
};
}, [mindmap]);
}, [mindmap, updateImgToolbarPos]);
// 悬浮图片位置工具条
// 处理图片删除/恢复(支持撤销)
useEffect(() => {
if (!mindmap || !docId) return;
console.log("[MindmapBlock] Setting up image deletion monitoring");
// 初始化:收集当前所有图片 URL
const initialData = mindmap.getData?.();
if (initialData) {
const urls = collectImageUrls(initialData);
currentImageUrlsRef.current = new Set(urls);
console.log("[MindmapBlock] Initial image URLs:", urls.length, "Map size:", signedUrlToAssetIdRef.current.size);
console.log("[MindmapBlock] Initial URLs:", urls);
}
// 处理数据变化
const handleDataChange = () => {
console.log("[MindmapBlock] data_change event fired!");
const newData = mindmap.getData?.();
if (!newData) return;
const newUrls = collectImageUrls(newData);
const newUrlsSet = new Set(newUrls);
const oldUrlsSet = currentImageUrlsRef.current;
console.log("[MindmapBlock] Old URLs:", Array.from(oldUrlsSet));
console.log("[MindmapBlock] New URLs:", newUrls);
console.log("[MindmapBlock] Map entries:", Array.from(signedUrlToAssetIdRef.current.entries()));
// 检测被删除的图片(在旧集合中但不在新集合中)
const deletedUrls: string[] = [];
oldUrlsSet.forEach(url => {
if (!newUrlsSet.has(url)) {
deletedUrls.push(url);
}
});
// 检测新增的图片(在新集合中但不在旧集合中)
const addedUrls: string[] = [];
newUrlsSet.forEach(url => {
if (!oldUrlsSet.has(url)) {
addedUrls.push(url);
}
});
// 处理删除的图片
if (deletedUrls.length > 0) {
console.log("[MindmapBlock] Detected deleted URLs:", deletedUrls);
const assetIdsToDelete: string[] = [];
deletedUrls.forEach(url => {
const assetId = signedUrlToAssetIdRef.current.get(url);
console.log("[MindmapBlock] URL:", url.substring(0, 100), "-> Asset ID:", assetId);
if (assetId) {
assetIdsToDelete.push(assetId);
} else {
console.warn("[MindmapBlock] Asset ID not found in map for URL:", url.substring(0, 100));
}
});
console.log("[MindmapBlock] Asset IDs to delete:", assetIdsToDelete);
if (assetIdsToDelete.length > 0) {
deleteImageAssets(assetIdsToDelete);
} else {
console.warn("[MindmapBlock] No asset IDs found for deleted URLs!");
}
}
// 处理恢复的图片(可能是撤销操作)
if (addedUrls.length > 0) {
console.log("[MindmapBlock] Detected added URLs:", addedUrls);
const assetIdsToRestore: string[] = [];
addedUrls.forEach(url => {
const assetId = signedUrlToAssetIdRef.current.get(url);
if (assetId && deletedAssetIdsRef.current.has(assetId)) {
assetIdsToRestore.push(assetId);
}
});
console.log("[MindmapBlock] Asset IDs to restore:", assetIdsToRestore);
if (assetIdsToRestore.length > 0) {
restoreImageAssets(assetIdsToRestore);
}
}
// 更新当前图片集合
currentImageUrlsRef.current = newUrlsSet;
};
// 监听多种事件
mindmap.on?.("data_change", handleDataChange);
mindmap.on?.("back_forward", handleDataChange);
mindmap.on?.("node_data_change", handleDataChange);
console.log("[MindmapBlock] Event listeners registered");
return () => {
console.log("[MindmapBlock] Cleaning up event listeners");
mindmap.off?.("data_change", handleDataChange);
mindmap.off?.("back_forward", handleDataChange);
mindmap.off?.("node_data_change", handleDataChange);
};
}, [mindmap, docId]);
// 悬浮图片位置工具条(参考 NodeImgPlacementToolbar.vue
const renderImgToolbar = () => {
if (!imgToolbarState.show) return null;
// 预览打开时隐藏工具栏
if (!imgToolbarState.show || showImageViewer) return null;
const { x, y, placement } = imgToolbarState;
const setPlacement = (p: "top" | "bottom" | "left" | "right") => {
setImagePosition(p);
applyToActiveNodes(mindmap, (node) =>
mindmap?.execCommand?.("SET_NODE_STYLES", node, { imgPlacement: p }),
applyToActiveNodes(mindmap, (n) =>
mindmap?.execCommand?.("SET_NODE_STYLES", n, { imgPlacement: p }),
);
setImgToolbarState((s) => ({ ...s, placement: p }));
};
const btn = (
// 位置按钮组件
const PosBtn = (
p: typeof placement,
Icon: React.ComponentType<{ className?: string }>,
title: string,
@@ -2055,12 +2402,11 @@ const MindmapBlockView = ({
<button
key={p}
type="button"
className={`flex h-8 w-8 items-center justify-center rounded border bg-white/90 text-gray-700 shadow ${
placement === p ? "border-blue-500 text-blue-600" : "border-gray-200"
className={`flex h-8 w-8 items-center justify-center rounded border bg-white text-gray-700 shadow-sm ${
placement === p ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200 hover:bg-gray-100"
}`}
onMouseDown={(e) => {
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setPlacement(p);
}}
title={title}
@@ -2068,24 +2414,18 @@ const MindmapBlockView = ({
<Icon className="h-4 w-4" />
</button>
);
return (
<div
ref={imgToolbarRef}
className="pointer-events-auto fixed z-[9999] flex gap-1 rounded-lg bg-black/50 p-2 backdrop-blur"
style={{ left: x, top: y - 42 }}
onMouseEnter={() => {
imgToolbarHover.current = true;
setImgToolbarState((s) => ({ ...s, show: true }));
}}
onMouseLeave={() => {
imgToolbarHover.current = false;
setImgToolbarState((s) => ({ ...s, show: false }));
}}
className="pointer-events-auto fixed z-[9999] flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 shadow-md"
style={{ left: x, top: y }}
onClick={(e) => e.stopPropagation()}
>
{btn("top", ArrowUp, "顶部")}
{btn("bottom", ArrowDown, "底部")}
{btn("left", ArrowLeft, "靠左")}
{btn("right", ArrowRight, "靠右")}
{PosBtn("top", ArrowUp, "顶部")}
{PosBtn("bottom", ArrowDown, "底部")}
{PosBtn("left", ArrowLeft, "靠左")}
{PosBtn("right", ArrowRight, "靠右")}
</div>
);
};
@@ -2122,22 +2462,41 @@ const MindmapBlockView = ({
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_NOTE", node, note));
};
const handleImageConfirm = () => {
const handleImageConfirm = async () => {
const url = imageUrl.trim();
const width = Number(imageWidth) || 260;
const height = Number(imageHeight) || 200;
// 获取原始图片尺寸,如果没有则使用默认值
let width = Number(imageWidth) || 0;
let height = Number(imageHeight) || 0;
if (!url) {
window.alert("请输入图片链接");
return;
}
setShowImageModal(false);
// 保存旧的 signed URL(如果存在),用于后续删除检测
const oldData = mindmap?.getData?.();
const oldUrls = oldData ? collectImageUrls(oldData) : [];
// 如果是 asset:id 格式,需要先转换为 signed URL 供显示
let displayUrl = url;
if (url.startsWith("asset:")) {
displayUrl = await resolveImageUrl(url);
// 记录映射关系供保存时使用
if (displayUrl !== url) {
const assetId = url.replace("asset:", "");
signedUrlToAssetIdRef.current.set(displayUrl, assetId);
console.log("[MindmapBlock] New image mapped:", displayUrl.substring(0, 80), "-> Asset ID:", assetId);
}
}
applyToActiveNodes(mindmap, (node) =>
mindmap?.execCommand("SET_NODE_IMAGE", node, {
url,
url: displayUrl,
width,
height,
title: imageTitle || "",
position: imagePosition || "top",
custom: false, // 让 simple-mind-map 自动缩放到合适尺寸
}),
);
applyToActiveNodes(mindmap, (node) =>
@@ -2145,6 +2504,16 @@ const MindmapBlockView = ({
imgPlacement: imagePosition || "top",
}),
);
// 等待命令执行后更新 currentImageUrlsRef
setTimeout(() => {
const newData = mindmap?.getData?.();
if (newData) {
const newUrls = collectImageUrls(newData);
currentImageUrlsRef.current = new Set(newUrls);
console.log("[MindmapBlock] Updated currentImageUrlsRef after insert:", newUrls);
}
}, 100);
};
const handleAttachment = () => {
@@ -2376,7 +2745,16 @@ const MindmapBlockView = ({
const imageViewer = showImageViewer ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={() => setShowImageViewer(false)}>
<div className="relative max-h-full max-w-5xl text-white">
<div
className="relative max-h-full max-w-5xl text-white"
onClick={(e) => e.stopPropagation()}
onWheel={(e) => {
e.preventDefault();
e.stopPropagation();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setViewerZoom((prev) => Math.max(0.1, Math.min(5, prev + delta)));
}}
>
<button
className="absolute -top-3 -right-3 rounded-full bg-white/90 px-2 py-1 text-sm text-gray-700 shadow"
onClick={(e) => {
@@ -2387,21 +2765,46 @@ const MindmapBlockView = ({
</button>
<div className="mb-2 flex items-center justify-between gap-4 px-1 text-xs text-gray-200">
<span className="truncate">{viewerMeta.title || "图片预览"}</span>
<span className="shrink-0">{viewerMeta.width && viewerMeta.height ? `${viewerMeta.width} × ${viewerMeta.height}px` : ""}</span>
<span className="truncate">{viewerMeta.title || "图片预览"}</span>
<div className="flex items-center gap-3">
<span className="shrink-0">{viewerMeta.width && viewerMeta.height ? `${viewerMeta.width} × ${viewerMeta.height}px` : ""}</span>
<span className="shrink-0 text-blue-300">{Math.round(viewerZoom * 100)}%</span>
<button
className="shrink-0 rounded bg-white/20 px-2 py-0.5 hover:bg-white/30"
onClick={() => setViewerZoom((prev) => Math.max(0.1, prev - 0.2))}
>
-
</button>
<button
className="shrink-0 rounded bg-white/20 px-2 py-0.5 hover:bg-white/30"
onClick={() => setViewerZoom(1)}
>
</button>
<button
className="shrink-0 rounded bg-white/20 px-2 py-0.5 hover:bg-white/30"
onClick={() => setViewerZoom((prev) => Math.min(5, prev + 0.2))}
>
+
</button>
</div>
</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={viewerSrc}
alt={viewerMeta.title || "预览"}
className="max-h-[80vh] max-w-[80vw] rounded-lg shadow-2xl object-contain bg-white"
className="max-h-[80vh] max-w-[80vw] rounded-lg shadow-2xl object-contain bg-white transition-transform duration-100"
style={{ transform: `scale(${viewerZoom})` }}
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
) : null;
const imgToolbar = renderImgToolbar();
// 使用 createPortal 将工具栏挂载到 body,确保 position: fixed 相对于视口
const imgToolbar = typeof document !== "undefined" && imgToolbarState.show
? createPortal(renderImgToolbar(), document.body)
: null;
const imageModal = showImageModal ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
@@ -2423,7 +2826,15 @@ const MindmapBlockView = ({
</button>
<span className="text-xs text-gray-400 truncate">
{imageUrl.startsWith("data:") ? "已选择本地图片" : "未选择文件"}
{uploadingImage
? "上传中..."
: imageUrl.startsWith("asset:")
? "已选择图片"
: imageUrl.startsWith("data:")
? "已选择本地图片"
: imageUrl
? "已输入图片地址"
: "未选择文件"}
</span>
<input
ref={fileInputForImage}
@@ -2433,17 +2844,42 @@ const MindmapBlockView = ({
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
const dataUrl = String(reader.result);
setImageUrl(dataUrl);
const size = await getImageSizeSafe(dataUrl);
if (size) {
setImageWidth(size.width);
setImageHeight(size.height);
if (!workspaceId || !docId) {
window.alert("缺少空间信息,无法上传文件");
return;
}
setUploadingImage(true);
try {
// 先获取原始图片尺寸
const localSize = await getImageSizeSafe(URL.createObjectURL(file));
if (localSize) {
setImageWidth(localSize.width);
setImageHeight(localSize.height);
}
};
reader.readAsDataURL(file);
const form = new FormData();
form.append("file", file);
form.append("workspaceId", workspaceId);
form.append("documentId", docId);
const response = await fetch("/api/media/upload", {
method: "POST",
body: form,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "上传失败");
}
const payload = (await response.json()) as { mindmapUrl?: string };
if (!payload.mindmapUrl) {
throw new Error("返回数据缺少图片地址");
}
setImageUrl(payload.mindmapUrl);
} catch (e) {
window.alert(e instanceof Error ? e.message : String(e));
} finally {
setUploadingImage(false);
if (fileInputForImage.current) fileInputForImage.current.value = "";
}
}}
/>
</div>
@@ -2619,6 +3055,7 @@ const MindmapBlockView = ({
/>
<MindmapCount mindmap={mindmap} />
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
<MindmapContextMenu mindmap={mindmap} />
{imgToolbar}
{noteModal}
{imageModal}
@@ -2710,6 +3147,7 @@ const MindmapBlockView = ({
/>
<MindmapCount mindmap={mindmap} />
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
<MindmapContextMenu mindmap={mindmap} />
{imgToolbar}
{noteModal}
{imageModal}