图标可移动
This commit is contained in:
@@ -13,11 +13,24 @@ import { MindmapNavigator } from "./MindmapNavigator";
|
||||
import { MindmapMiniMap } from "./MindmapMiniMap";
|
||||
import { MindmapCount } from "./MindmapCount";
|
||||
import type { SidebarPanel } from "./mindmapSidebarConfig";
|
||||
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";
|
||||
// @ts-expect-error 第三方库缺少类型定义
|
||||
import { mergerIconList } from "simple-mind-map/src/utils/index.js";
|
||||
|
||||
// 安全获取图片尺寸
|
||||
const getImageSizeSafe = (url: string): Promise<{ width: number; height: number } | null> =>
|
||||
new Promise((resolve) => {
|
||||
if (!url) return resolve(null);
|
||||
const img = new Image();
|
||||
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
type MindMapInstance = {
|
||||
execCommand: (...args: unknown[]) => void;
|
||||
destroy: () => void;
|
||||
@@ -190,6 +203,7 @@ const MindmapBlockView = ({
|
||||
{ default: Select },
|
||||
{ default: Drag },
|
||||
{ default: KeyboardNavigation },
|
||||
{ default: NodeImgAdjust },
|
||||
] = await Promise.all([
|
||||
import("simple-mind-map"),
|
||||
import("simple-mind-map/src/plugins/Painter.js"),
|
||||
@@ -202,6 +216,7 @@ const MindmapBlockView = ({
|
||||
import("simple-mind-map/src/plugins/Select.js"),
|
||||
import("simple-mind-map/src/plugins/Drag.js"),
|
||||
import("simple-mind-map/src/plugins/KeyboardNavigation.js"),
|
||||
import("simple-mind-map/src/plugins/NodeImgAdjust.js"),
|
||||
]);
|
||||
|
||||
const plugins = [
|
||||
@@ -215,6 +230,7 @@ const MindmapBlockView = ({
|
||||
{ name: "Select", plugin: Select },
|
||||
{ name: "Drag", plugin: Drag },
|
||||
{ name: "KeyboardNavigation", plugin: KeyboardNavigation },
|
||||
{ name: "NodeImgAdjust", plugin: NodeImgAdjust },
|
||||
];
|
||||
|
||||
plugins.forEach(({ name, plugin }) => {
|
||||
@@ -347,12 +363,144 @@ const MindmapBlockView = ({
|
||||
}
|
||||
};
|
||||
|
||||
const [showImageModal, setShowImageModal] = useState(false);
|
||||
const [imageUrl, setImageUrl] = useState("");
|
||||
const [imageWidth, setImageWidth] = useState<number | string>(260);
|
||||
const [imageHeight, setImageHeight] = useState<number | string>(200);
|
||||
const [imageTitle, setImageTitle] = useState("");
|
||||
const [imagePosition, setImagePosition] = useState<string>("top");
|
||||
const fileInputForImage = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// 图片预览(双击节点图片)
|
||||
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,
|
||||
x: 0,
|
||||
y: 0,
|
||||
placement: "top" as "top" | "bottom" | "left" | "right",
|
||||
});
|
||||
const imgToolbarHover = useRef(false);
|
||||
|
||||
const handleImage = () => {
|
||||
const url = createSimplePrompt("请输入图片链接");
|
||||
if (!url) return;
|
||||
applyToActiveNodes(mindmap, (node) => {
|
||||
mindmap?.execCommand("SET_NODE_IMAGE", node, { url, width: 260, height: 200, title: "" });
|
||||
});
|
||||
const first = mindmap?.renderer?.activeNodeList?.[0] as any;
|
||||
if (first?.getStyle) {
|
||||
const placement = first.getStyle("imgPlacement", false) as string;
|
||||
if (placement) setImagePosition(placement);
|
||||
}
|
||||
setShowImageModal(true);
|
||||
};
|
||||
|
||||
// 预览:监听 mindmap 事件
|
||||
useEffect(() => {
|
||||
if (!mindmap) return;
|
||||
const handler = (node: any, e: any) => {
|
||||
e?.stopPropagation?.();
|
||||
e?.preventDefault?.();
|
||||
const src =
|
||||
node?.nodeData?.data?.image ||
|
||||
node?.getData?.("image") ||
|
||||
node?.data?.image;
|
||||
if (src) {
|
||||
setViewerSrc(src);
|
||||
setViewerNode(node);
|
||||
const size = node?.getData?.("imageSize") || {};
|
||||
const title = node?.getData?.("imageTitle") || "";
|
||||
setViewerMeta({
|
||||
title: typeof title === "string" ? title : "",
|
||||
width: Number(size?.width) || undefined,
|
||||
height: Number(size?.height) || undefined,
|
||||
});
|
||||
setShowImageViewer(true);
|
||||
}
|
||||
};
|
||||
const onActive = () => {
|
||||
const list = mindmap.renderer?.activeNodeList || [];
|
||||
const has = list.some((n: any) => !!n?.getData?.("image"));
|
||||
setHasActiveImg(has);
|
||||
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;
|
||||
if (!bbox) return;
|
||||
setImagePosition(placement);
|
||||
setImgToolbarState({
|
||||
show: true,
|
||||
x: bbox.left,
|
||||
y: bbox.top,
|
||||
placement,
|
||||
});
|
||||
};
|
||||
const hideToolbar = () => {
|
||||
if (!imgToolbarHover.current) {
|
||||
setImgToolbarState((s) => ({ ...s, show: false }));
|
||||
}
|
||||
};
|
||||
mindmap.on?.("node_img_dblclick", handler);
|
||||
mindmap.on?.("node_active", onActive);
|
||||
mindmap.on?.("node_img_click", showToolbarOnClick);
|
||||
mindmap.on?.("draw_click", 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]);
|
||||
|
||||
// 悬浮图片位置工具条
|
||||
const renderImgToolbar = () => {
|
||||
if (!imgToolbarState.show) 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 }),
|
||||
);
|
||||
setImgToolbarState((s) => ({ ...s, placement: p }));
|
||||
};
|
||||
const btn = (p: typeof placement, Icon: any, title: string) => (
|
||||
<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"
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setPlacement(p);
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
<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 }));
|
||||
}}
|
||||
>
|
||||
{btn("top", ArrowUp, "顶部")}
|
||||
{btn("bottom", ArrowDown, "底部")}
|
||||
{btn("left", ArrowLeft, "靠左")}
|
||||
{btn("right", ArrowRight, "靠右")}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleIcon = () => {
|
||||
@@ -387,6 +535,31 @@ const MindmapBlockView = ({
|
||||
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_NOTE", node, note));
|
||||
};
|
||||
|
||||
const handleImageConfirm = () => {
|
||||
const url = imageUrl.trim();
|
||||
const width = Number(imageWidth) || 260;
|
||||
const height = Number(imageHeight) || 200;
|
||||
if (!url) {
|
||||
window.alert("请输入图片链接");
|
||||
return;
|
||||
}
|
||||
setShowImageModal(false);
|
||||
applyToActiveNodes(mindmap, (node) =>
|
||||
mindmap?.execCommand("SET_NODE_IMAGE", node, {
|
||||
url,
|
||||
width,
|
||||
height,
|
||||
title: imageTitle || "",
|
||||
position: imagePosition || "top",
|
||||
}),
|
||||
);
|
||||
applyToActiveNodes(mindmap, (node) =>
|
||||
mindmap?.execCommand?.("SET_NODE_STYLES", node, {
|
||||
imgPlacement: imagePosition || "top",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleAttachment = () => {
|
||||
const url = createSimplePrompt("附件链接(http/https)");
|
||||
if (!url) return;
|
||||
@@ -490,8 +663,8 @@ const MindmapBlockView = ({
|
||||
const noteModal = showNoteModal ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-4xl rounded-xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<span className="text-lg font-semibold text-gray-800">备注</span>
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<span className="text-lg font-semibold text-gray-800">备注</span>
|
||||
<button className="text-gray-400 hover:text-gray-600" onClick={() => setShowNoteModal(false)}>
|
||||
✕
|
||||
</button>
|
||||
@@ -522,6 +695,188 @@ const MindmapBlockView = ({
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
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">
|
||||
<button
|
||||
className="absolute -top-3 -right-3 rounded-full bg-white/90 px-2 py-1 text-sm text-gray-700 shadow"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowImageViewer(false);
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</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>
|
||||
</div>
|
||||
<img
|
||||
src={viewerSrc}
|
||||
alt={viewerMeta.title || "预览"}
|
||||
className="max-h-[80vh] max-w-[80vw] rounded-lg shadow-2xl object-contain bg-white"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const imgToolbar = renderImgToolbar();
|
||||
|
||||
const imageModal = showImageModal ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-3xl rounded-xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<span className="text-lg font-semibold text-gray-800">插入图片</span>
|
||||
<button className="text-gray-400 hover:text-gray-600" onClick={() => setShowImageModal(false)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4 px-4 py-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-gray-600">方式一:上传图片</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
onClick={() => fileInputForImage.current?.click()}
|
||||
>
|
||||
选择文件
|
||||
</button>
|
||||
<span className="text-xs text-gray-400 truncate">
|
||||
{imageUrl.startsWith("data:") ? "已选择本地图片" : "未选择文件"}
|
||||
</span>
|
||||
<input
|
||||
ref={fileInputForImage}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
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);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-gray-600">方式二:图片地址</Label>
|
||||
<Input
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
placeholder="https://example.com/image.png"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-gray-600">快捷位置</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 rounded border px-2 py-2 text-sm ${imagePosition === "top" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
|
||||
onClick={() => setImagePosition("top")}
|
||||
title="顶部"
|
||||
>
|
||||
<ArrowUp className="mx-auto h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 rounded border px-2 py-2 text-sm ${imagePosition === "bottom" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
|
||||
onClick={() => setImagePosition("bottom")}
|
||||
title="底部"
|
||||
>
|
||||
<ArrowDown className="mx-auto h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 rounded border px-2 py-2 text-sm ${imagePosition === "left" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
|
||||
onClick={() => setImagePosition("left")}
|
||||
title="靠左"
|
||||
>
|
||||
<ArrowLeft className="mx-auto h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 rounded border px-2 py-2 text-sm ${imagePosition === "right" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
|
||||
onClick={() => setImagePosition("right")}
|
||||
title="靠右"
|
||||
>
|
||||
<ArrowRight className="mx-auto h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">宽度(px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={imageWidth}
|
||||
onChange={(e) => setImageWidth(e.target.value)}
|
||||
min={10}
|
||||
placeholder="260"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">高度(px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={imageHeight}
|
||||
onChange={(e) => setImageHeight(e.target.value)}
|
||||
min={10}
|
||||
placeholder="200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">标题(可选)</Label>
|
||||
<Input
|
||||
value={imageTitle}
|
||||
onChange={(e) => setImageTitle(e.target.value)}
|
||||
placeholder="图片标题"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">位置</Label>
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-gray-200 px-3 text-sm"
|
||||
value={imagePosition}
|
||||
onChange={(e) => setImagePosition(e.target.value)}
|
||||
>
|
||||
<option value="center">居中</option>
|
||||
<option value="top">顶部</option>
|
||||
<option value="bottom">底部</option>
|
||||
<option value="left">靠左</option>
|
||||
<option value="right">靠右</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">可选择本地图片或粘贴 URL,默认尺寸 260×200。</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 border-t px-4 py-3">
|
||||
<button
|
||||
className="rounded-md border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50"
|
||||
onClick={() => setShowImageModal(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||
onClick={handleImageConfirm}
|
||||
>
|
||||
插入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (fullscreen) {
|
||||
return (
|
||||
<div className="relative h-screen w-screen overflow-hidden bg-white">
|
||||
@@ -551,7 +906,10 @@ const MindmapBlockView = ({
|
||||
<MindmapCount mindmap={mindmap} />
|
||||
{/* @ts-expect-error mindmap type */}
|
||||
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
|
||||
{imgToolbar}
|
||||
{noteModal}
|
||||
{imageModal}
|
||||
{imageViewer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -587,7 +945,10 @@ const MindmapBlockView = ({
|
||||
<MindmapCount mindmap={mindmap} />
|
||||
{/* @ts-expect-error mindmap type */}
|
||||
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
|
||||
{imgToolbar}
|
||||
{noteModal}
|
||||
{imageModal}
|
||||
{imageViewer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user