图标可移动

This commit is contained in:
liaibo
2025-12-31 19:26:38 +08:00
parent f46ec3c2f5
commit 314b6f9a7e
4 changed files with 500 additions and 21 deletions
+3
View File
@@ -39,3 +39,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
#test
**/test/
@@ -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>
);
@@ -32,6 +32,9 @@ import {
themeList,
layoutList,
outlineDepthOptions,
backgroundRepeatList,
backgroundPositionList,
backgroundSizeList,
} from "./mindmapOptions";
import iconConfig from "./mindmapIconConfig";
import imageConfig from "./mindmapImageConfig";
@@ -288,7 +291,8 @@ const BaseStylePanel = ({ mindmap }: { mindmap: any }) => {
const newStyle: Record<string, any> = {};
[
"backgroundColor", "lineColor", "lineWidth", "lineStyle",
"paddingX", "paddingY"
"paddingX", "paddingY",
"backgroundImage", "backgroundRepeat", "backgroundPosition", "backgroundSize",
].forEach(key => {
newStyle[key] = mindmap.getThemeConfig(key);
});
@@ -315,6 +319,31 @@ const BaseStylePanel = ({ mindmap }: { mindmap: any }) => {
value={style.backgroundColor}
onChange={(v) => updateThemeConfig("backgroundColor", v)}
/>
<div className="space-y-2">
<Label className="text-[11px] text-gray-500"></Label>
<Input
value={style.backgroundImage || ""}
onChange={(e) => updateThemeConfig("backgroundImage", e.target.value || "")}
placeholder="可粘贴图片地址"
/>
<div className="grid grid-cols-3 gap-2 text-[12px] text-gray-600">
<NativeSelect
value={style.backgroundRepeat || "no-repeat"}
options={backgroundRepeatList.map((i) => ({ label: i.name, value: i.value }))}
onChange={(v) => updateThemeConfig("backgroundRepeat", v)}
/>
<NativeSelect
value={style.backgroundPosition || "center center"}
options={backgroundPositionList.map((i) => ({ label: i.name, value: i.value }))}
onChange={(v) => updateThemeConfig("backgroundPosition", v)}
/>
<NativeSelect
value={style.backgroundSize || "cover"}
options={backgroundSizeList.map((i) => ({ label: i.name, value: i.value }))}
onChange={(v) => updateThemeConfig("backgroundSize", v)}
/>
</div>
</div>
</div>
<div className="space-y-3">
@@ -512,12 +541,12 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
<button
key={`${group.type}-${item.name}`}
onClick={() => addIcon(group.type, item.name)}
className="flex h-9 w-9 items-center justify-center rounded border border-gray-200 bg-white text-xs hover:border-blue-400 hover:shadow-sm"
className="flex h-9 w-9 items-center justify-center rounded border border-gray-200 bg-white text-xs hover:border-blue-400 hover:shadow-sm overflow-hidden"
>
{item.icon ? (
typeof item.icon === "string" && item.icon.trim().startsWith("<svg") ? (
<span
className="inline-flex h-6 w-6 items-center justify-center"
className="inline-flex h-6 w-6 items-center justify-center overflow-hidden"
// @ts-expect-error: dangerouslySetInnerHTML 用于复用官方 SVG 片段
dangerouslySetInnerHTML={{ __html: item.icon }}
/>
@@ -691,19 +720,76 @@ const FormulaPanel = ({ mindmap }: { mindmap: any }) => {
);
};
const NotePanel = ({ mindmap }: { mindmap: any }) => {
const [note, setNote] = useState("备注内容");
const apply = () => {
const active = mindmap?.renderer?.activeNodeList || [];
active.forEach((node: any) => mindmap?.execCommand?.("SET_NODE_NOTE", node, note));
const NotePanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapNode[] }) => {
const [note, setNote] = useState("");
const readNote = (node: any) => {
try {
return (
node?.getData?.("note") ??
node?.nodeData?.data?.note ??
node?.data?.note ??
""
);
} catch {
return "";
}
};
// 当选中节点变化时,自动展示首个节点的备注
useEffect(() => {
if (!activeNodes?.length) {
setNote("");
return;
}
const current = readNote(activeNodes[0]);
setNote(typeof current === "string" ? current : "");
}, [activeNodes]);
const getActiveList = () =>
mindmap?.renderer?.activeNodeList?.length
? mindmap.renderer.activeNodeList
: activeNodes || [];
const apply = () => {
const list = getActiveList();
list.forEach((node: any) => mindmap?.execCommand?.("SET_NODE_NOTE", node, note));
};
const clearNote = () => {
const list = getActiveList();
list.forEach((node: any) => mindmap?.execCommand?.("SET_NODE_NOTE", node, ""));
setNote("");
};
if (!activeNodes?.length) {
return <div className="p-4 text-sm text-gray-400"></div>;
}
return (
<div className="space-y-3">
<Label className="text-xs text-gray-500"></Label>
<Input value={note} onChange={(e) => setNote(e.target.value)} />
<button className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600" onClick={apply}>
</button>
<Label className="text-xs text-gray-500"></Label>
<textarea
className="w-full rounded-md border border-gray-200 p-2 text-sm"
rows={38}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="输入备注内容"
/>
<div className="flex gap-2">
<button
className="flex-1 rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600"
onClick={apply}
>
/
</button>
<button
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={clearNote}
>
</button>
</div>
</div>
);
};
@@ -750,7 +836,7 @@ export const MindmapSidebar = ({ mindmap, activeNodes, activeTab, onClose }: Sid
case "formula":
return <FormulaPanel mindmap={mindmap} />;
case "note":
return <NotePanel mindmap={mindmap} />;
return <NotePanel mindmap={mindmap} activeNodes={activeNodes} />;
case "ai":
return <AiPanel />;
default:
@@ -119,3 +119,32 @@ export const outlineDepthOptions = [
{ name: "两级", value: 2 },
{ name: "三级", value: 3 },
];
// 背景图重复方式
export const backgroundRepeatList = [
{ name: "不重复", value: "no-repeat" },
{ name: "重复", value: "repeat" },
{ name: "水平方向重复", value: "repeat-x" },
{ name: "垂直方向重复", value: "repeat-y" },
];
// 背景图定位
export const backgroundPositionList = [
{ name: "默认", value: "0% 0%" },
{ name: "左上", value: "left top" },
{ name: "左中", value: "left center" },
{ name: "左下", value: "left bottom" },
{ name: "右上", value: "right top" },
{ name: "右中", value: "right center" },
{ name: "右下", value: "right bottom" },
{ name: "中上", value: "center top" },
{ name: "居中", value: "center center" },
{ name: "中下", value: "center bottom" },
];
// 背景图大小
export const backgroundSizeList = [
{ name: "自动", value: "auto" },
{ name: "覆盖", value: "cover" },
{ name: "保持", value: "contain" },
];