0.1.07 文件树拖拽与多思维导图
This commit is contained in:
@@ -30,7 +30,7 @@ import type { CustomBlockSchema } from "../schema";
|
||||
type MediaAlign = "left" | "center" | "right";
|
||||
|
||||
type MediaBlockRenderProps = {
|
||||
block: Block<CustomBlockSchema>;
|
||||
block: Block<CustomBlockSchema> & { props: any };
|
||||
editor: BlockNoteEditor<CustomBlockSchema>;
|
||||
};
|
||||
|
||||
@@ -75,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 }: any) => {
|
||||
const { openPicker } = useImagePicker();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const fileUrl = block.props.fileUrl as string;
|
||||
|
||||
@@ -334,16 +334,17 @@ const MindmapBlockView = ({
|
||||
: ""),
|
||||
[block.props.docId],
|
||||
);
|
||||
const mindmapId = block.id;
|
||||
|
||||
useEffect(() => {
|
||||
hasLocalEditsRef.current = false;
|
||||
applyingRemoteRef.current = false;
|
||||
}, [docId]);
|
||||
|
||||
const autosaveKey = useMemo(
|
||||
() => `${STORAGE_PREFIX}${docId || block.id}`,
|
||||
[block.id, docId],
|
||||
);
|
||||
const autosaveKey = useMemo(() => {
|
||||
if (docId) return `${STORAGE_PREFIX}${docId}:${mindmapId}`;
|
||||
return `${STORAGE_PREFIX}${mindmapId}`;
|
||||
}, [docId, mindmapId]);
|
||||
const initialDataRef = useRef<unknown>(null);
|
||||
if (initialDataRef.current === null) {
|
||||
const cached = typeof window !== "undefined" ? window.localStorage.getItem(autosaveKey) : null;
|
||||
@@ -364,7 +365,8 @@ const MindmapBlockView = ({
|
||||
if (!docId) return;
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/mindmap/${docId}`);
|
||||
// 多导图:按 docId + mindmapId 拉取
|
||||
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`);
|
||||
if (!resp.ok) return;
|
||||
const payload = await resp.json().catch(() => null);
|
||||
const data = payload?.data;
|
||||
@@ -390,7 +392,7 @@ const MindmapBlockView = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [docId, mindmap]);
|
||||
}, [docId, mindmap, mindmapId]);
|
||||
|
||||
// 记录“最近一次指针交互是否发生在思维导图块内”,用于键盘快捷键作用域
|
||||
useEffect(() => {
|
||||
@@ -617,26 +619,27 @@ const MindmapBlockView = ({
|
||||
editor.updateBlock(block, { props: { ...block.props, data: safe } });
|
||||
if (docId) {
|
||||
// 同步到本地文件 + Supabase(弱依赖)
|
||||
fetch(`/api/mindmap/${docId}`, {
|
||||
fetch(`/api/mindmap/${docId}/${mindmapId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ data: safe }),
|
||||
})
|
||||
.then((resp) => {
|
||||
if (resp.ok) {
|
||||
const fileName = `mindmap-${mindmapId}.json`;
|
||||
emitAssetsChanged(docId, {
|
||||
id: `mindmap-${docId}`,
|
||||
id: mindmapId,
|
||||
document_id: docId,
|
||||
asset_type: "mindmap",
|
||||
file_name: "mindmap.json",
|
||||
file_url: `/documents/${docId}`,
|
||||
file_name: fileName,
|
||||
file_url: `/documents/${docId}/${fileName}`,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => console.warn("思维导图同步失败", err));
|
||||
}
|
||||
},
|
||||
[autosaveKey, block, docId, editor],
|
||||
[autosaveKey, block, docId, editor, mindmapId],
|
||||
);
|
||||
|
||||
const debouncedPersist = useDebouncedCallback((data: unknown) => {
|
||||
@@ -652,7 +655,7 @@ const MindmapBlockView = ({
|
||||
);
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/mindmap/${docId}`, {
|
||||
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ data }),
|
||||
@@ -668,16 +671,17 @@ const MindmapBlockView = ({
|
||||
console.warn("初次创建思维导图文件失败", err);
|
||||
} finally {
|
||||
// 即便远端失败,也通知侧边栏刷新,保证本地文件树及时更新
|
||||
const fileName = `mindmap-${mindmapId}.json`;
|
||||
emitAssetsChanged(docId, {
|
||||
id: `mindmap-${docId}`,
|
||||
id: mindmapId,
|
||||
document_id: docId,
|
||||
asset_type: "mindmap",
|
||||
file_name: "mindmap.json",
|
||||
file_url: `/documents/${docId}`,
|
||||
file_name: fileName,
|
||||
file_url: `/documents/${docId}/${fileName}`,
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [docId, mindmap, initialDataRef]);
|
||||
}, [docId, mindmap, mindmapId, initialDataRef]);
|
||||
|
||||
// 初始选中根节点,后续不强制抢焦点,允许用户自由选择
|
||||
useEffect(() => {
|
||||
@@ -691,17 +695,18 @@ const MindmapBlockView = ({
|
||||
}
|
||||
}, [mindmap, activeNodes.length]);
|
||||
|
||||
// mindmap 实例一旦就绪,立刻向侧边栏广播,确保文件树即时显示 mindmap.json
|
||||
// mindmap 实例一旦就绪,立刻向侧边栏广播,确保文件树即时显示 mindmap-<id>.json
|
||||
useEffect(() => {
|
||||
if (!docId || !mindmap) return;
|
||||
const fileName = `mindmap-${mindmapId}.json`;
|
||||
emitAssetsChanged(docId, {
|
||||
id: `mindmap-${docId}`,
|
||||
id: mindmapId,
|
||||
document_id: docId,
|
||||
asset_type: "mindmap",
|
||||
file_name: "mindmap.json",
|
||||
file_url: `/documents/${docId}`,
|
||||
file_name: fileName,
|
||||
file_url: `/documents/${docId}/${fileName}`,
|
||||
});
|
||||
}, [docId, mindmap]);
|
||||
}, [docId, mindmap, mindmapId]);
|
||||
|
||||
useEffect(() => {
|
||||
let destroyed = false;
|
||||
@@ -982,6 +987,11 @@ const MindmapBlockView = ({
|
||||
if (typeof window !== "undefined") {
|
||||
// 便于开发阶段在控制台直接调试实例
|
||||
window.__mindmapInstance = instance;
|
||||
const w = window as unknown as {
|
||||
__mindmapInstancesById?: Record<string, MindMapInstance>;
|
||||
};
|
||||
if (!w.__mindmapInstancesById) w.__mindmapInstancesById = {};
|
||||
w.__mindmapInstancesById[mindmapId] = instance;
|
||||
}
|
||||
|
||||
setMindmap(instance);
|
||||
@@ -1087,9 +1097,13 @@ const MindmapBlockView = ({
|
||||
if (!docId || typeof window === "undefined") return false;
|
||||
try {
|
||||
const w = window as unknown as {
|
||||
__wolaiMindmapDeletingDocIds?: Set<string>;
|
||||
__wolaiMindmapDeletingKeys?: Set<string>;
|
||||
};
|
||||
return Boolean(w.__wolaiMindmapDeletingDocIds?.has(docId));
|
||||
const key = `${docId}:${mindmapId}`;
|
||||
return Boolean(
|
||||
w.__wolaiMindmapDeletingKeys?.has(docId) ||
|
||||
w.__wolaiMindmapDeletingKeys?.has(key),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -1111,7 +1125,7 @@ const MindmapBlockView = ({
|
||||
// ignore
|
||||
}
|
||||
if (docId) {
|
||||
fetch(`/api/mindmap/${docId}`, {
|
||||
fetch(`/api/mindmap/${docId}/${mindmapId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ data: safe }),
|
||||
@@ -1129,6 +1143,14 @@ const MindmapBlockView = ({
|
||||
if (window.__mindmapInstance === createdInstance) {
|
||||
window.__mindmapInstance = null;
|
||||
}
|
||||
try {
|
||||
const w = window as unknown as { __mindmapInstancesById?: Record<string, MindMapInstance> };
|
||||
if (w.__mindmapInstancesById && w.__mindmapInstancesById[mindmapId] === createdInstance) {
|
||||
delete w.__mindmapInstancesById[mindmapId];
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
setMindmap(null);
|
||||
mindmapRef.current = null;
|
||||
@@ -1628,7 +1650,7 @@ const MindmapBlockView = ({
|
||||
const confirmed = window.confirm("确认删除当前思维导图?此操作会移除文件并清空侧边栏记录。");
|
||||
if (!confirmed) return;
|
||||
deletingRef.current = true;
|
||||
const resp = await fetch(`/api/mindmap/${docId}`, { method: "DELETE" });
|
||||
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除思维导图失败");
|
||||
@@ -1640,9 +1662,14 @@ const MindmapBlockView = ({
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
emitAssetsChanged(docId, undefined, undefined, true);
|
||||
editor.removeBlocks([block.id]);
|
||||
}, [autosaveKey, block.id, docId, editor]);
|
||||
emitAssetsChanged(docId, undefined, undefined, false, [mindmapId]);
|
||||
// 同时会触发全局删除监听(ASSETS_CHANGED_EVENT)进行块移除,这里做 try/catch 避免重复删除报错
|
||||
try {
|
||||
editor.removeBlocks([block.id]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [autosaveKey, block.id, docId, editor, mindmapId]);
|
||||
|
||||
const toolbarProps = {
|
||||
canBack,
|
||||
@@ -1928,6 +1955,7 @@ const MindmapBlockView = ({
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
data-testid="mindmap-canvas"
|
||||
data-mindmap-id={mindmapId}
|
||||
contentEditable={false}
|
||||
/>
|
||||
|
||||
@@ -2011,6 +2039,7 @@ const MindmapBlockView = ({
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
data-testid="mindmap-canvas"
|
||||
data-mindmap-id={mindmapId}
|
||||
contentEditable={false}
|
||||
/>
|
||||
|
||||
|
||||
@@ -24,22 +24,15 @@ import {
|
||||
} from "./mindmapOptions";
|
||||
import iconConfig from "./mindmapIconConfig";
|
||||
import imageConfig from "./mindmapImageConfig";
|
||||
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
|
||||
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
|
||||
import type { MindMapNode } from "./mindmapTypes";
|
||||
// 避免 SSR 阶段触发浏览器依赖,改为运行时动态引入
|
||||
// @ts-expect-error 第三方库缺少类型定义
|
||||
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 };
|
||||
};
|
||||
|
||||
type MindMapNode = {
|
||||
getStyle: (prop: string, checkRoot?: boolean) => any;
|
||||
setStyle: (prop: string, value: any) => void;
|
||||
setIcon: (icons: string[]) => void;
|
||||
getData: (key: string) => any;
|
||||
};
|
||||
|
||||
type SidebarProps = {
|
||||
mindmap: any;
|
||||
activeNodes: MindMapNode[];
|
||||
@@ -118,12 +111,12 @@ const StylePanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
|
||||
}
|
||||
}, [activeNodes]);
|
||||
|
||||
const updateStyle = (prop: string, value: any) => {
|
||||
setStyle((prev) => ({ ...prev, [prop]: value }));
|
||||
activeNodes.forEach((node) => {
|
||||
node.setStyle(prop, value);
|
||||
});
|
||||
};
|
||||
const updateStyle = (prop: string, value: any) => {
|
||||
setStyle((prev) => ({ ...prev, [prop]: value }));
|
||||
activeNodes.forEach((node) => {
|
||||
node.setStyle?.(prop, value);
|
||||
});
|
||||
};
|
||||
|
||||
if (activeNodes.length === 0) {
|
||||
return (
|
||||
@@ -446,26 +439,27 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
|
||||
const addIcon = (type: string, name: string) => {
|
||||
const key = `${type}_${name}`;
|
||||
activeNodes.forEach((node) => {
|
||||
const icons = node.getData("icon") || [];
|
||||
const newIcons = icons.filter((i: string) => !i.startsWith(`${type}_`));
|
||||
const rawIcons = node.getData("icon");
|
||||
const icons = Array.isArray(rawIcons) ? (rawIcons as string[]) : [];
|
||||
const newIcons = icons.filter((i) => !i.startsWith(`${type}_`));
|
||||
newIcons.push(key);
|
||||
node.setIcon(newIcons);
|
||||
node.setIcon?.(newIcons);
|
||||
});
|
||||
};
|
||||
|
||||
const removeIcon = (type: string) => {
|
||||
activeNodes.forEach((node) => {
|
||||
const icons = node.getData("icon") || [];
|
||||
const newIcons = icons.filter((i: string) => !i.startsWith(`${type}_`));
|
||||
node.setIcon(newIcons);
|
||||
const rawIcons = node.getData("icon");
|
||||
const icons = Array.isArray(rawIcons) ? (rawIcons as string[]) : [];
|
||||
const newIcons = icons.filter((i) => !i.startsWith(`${type}_`));
|
||||
node.setIcon?.(newIcons);
|
||||
});
|
||||
};
|
||||
|
||||
const setSticker = (img: { url: string; width?: number; height?: number }) => {
|
||||
activeNodes.forEach((node) => {
|
||||
// simple-mind-map 支持 setImage 接收对象,包含 url/width/height
|
||||
// @ts-expect-error 第三方库无类型
|
||||
node.setImage({
|
||||
node.setImage?.({
|
||||
url: img.url,
|
||||
width: img.width || 100,
|
||||
height: img.height || 100,
|
||||
@@ -476,8 +470,7 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
|
||||
const clearSticker = () => {
|
||||
activeNodes.forEach((node) => {
|
||||
// 传入 null 以清除贴纸
|
||||
// @ts-expect-error 第三方库无类型
|
||||
node.setImage(null);
|
||||
node.setImage?.(null);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -511,7 +504,7 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.list.map((item) => (
|
||||
{group.list.map((item: any) => (
|
||||
<button
|
||||
key={`${group.type}-${item.name}`}
|
||||
onClick={() => addIcon(group.type, item.name)}
|
||||
@@ -521,7 +514,6 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
|
||||
typeof item.icon === "string" && item.icon.trim().startsWith("<svg") ? (
|
||||
<span
|
||||
className="inline-flex h-6 w-6 items-center justify-center overflow-hidden"
|
||||
// @ts-expect-error: dangerouslySetInnerHTML 用于复用官方 SVG 片段
|
||||
dangerouslySetInnerHTML={{ __html: item.icon }}
|
||||
/>
|
||||
) : (
|
||||
@@ -599,16 +591,13 @@ const OutlinePanel = ({ mindmap }: { mindmap: any }) => {
|
||||
if (!node || !r) return;
|
||||
// 仅通过已有方法触发激活,避免直接改 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 报错
|
||||
@@ -1215,7 +1204,6 @@ const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapN
|
||||
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",
|
||||
);
|
||||
|
||||
@@ -46,7 +46,7 @@ type ToolbarProps = {
|
||||
onExportMd: () => void;
|
||||
onExportTxt: () => void;
|
||||
onExportXmind: () => void;
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
};
|
||||
|
||||
const ToolbarButton = ({
|
||||
|
||||
@@ -42,10 +42,7 @@ const handleMapping: Record<
|
||||
const OnlineTableBlockComponent = ({
|
||||
block,
|
||||
editor,
|
||||
}: {
|
||||
block: Block<CustomBlockSchema, "onlineTable">;
|
||||
editor: BlockNoteEditor<CustomBlockSchema>;
|
||||
}) => {
|
||||
}: any) => {
|
||||
const { tableId } = block.props;
|
||||
const openTableFullScreen = useEditorBridgeStore((state) => state.bridge?.openTableFullScreen);
|
||||
const storedWidth = typeof block.props.width === "number" ? block.props.width : undefined;
|
||||
|
||||
@@ -177,7 +177,9 @@ export const parseMindManagerMmapFile = async (file: File): Promise<MindMapData>
|
||||
const entry = zip.file(zipPath) ?? zip.file(`/${zipPath}`);
|
||||
if (!entry) return null;
|
||||
const bytes = await entry.async("uint8array");
|
||||
const blob = new Blob([bytes], { type: mime });
|
||||
const rawBuffer = bytes.buffer as ArrayBuffer;
|
||||
const sliced = rawBuffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
const blob = new Blob([sliced], { type: mime });
|
||||
const dataUrl = await readBlobAsDataUrl(blob);
|
||||
const size = await getImageSizeFromBlob(blob);
|
||||
const width = size?.width ?? 0;
|
||||
@@ -230,4 +232,3 @@ export const parseMindManagerMmapFile = async (file: File): Promise<MindMapData>
|
||||
const tree = await walkTopic(rootTopicEl, true);
|
||||
return compactTree(tree, true);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user