From 314b6f9a7e23912c62dbb1db49f4fe9ae46df8d1 Mon Sep 17 00:00:00 2001 From: liaibo Date: Wed, 31 Dec 2025 19:26:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E6=A0=87=E5=8F=AF=E7=A7=BB=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wolai-frontend/.gitignore | 3 + .../components/editor/blocks/MindmapBlock.tsx | 375 +++++++++++++++++- .../editor/blocks/MindmapSidebar.tsx | 114 +++++- .../editor/blocks/mindmapOptions.ts | 29 ++ 4 files changed, 500 insertions(+), 21 deletions(-) diff --git a/wolai-frontend/.gitignore b/wolai-frontend/.gitignore index 5ef6a520..4c2970ab 100644 --- a/wolai-frontend/.gitignore +++ b/wolai-frontend/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +#test +**/test/ \ No newline at end of file diff --git a/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx index b3ac9c94..77181b3f 100644 --- a/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx +++ b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx @@ -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(260); + const [imageHeight, setImageHeight] = useState(200); + const [imageTitle, setImageTitle] = useState(""); + const [imagePosition, setImagePosition] = useState("top"); + const fileInputForImage = useRef(null); + + // 图片预览(双击节点图片) + const [showImageViewer, setShowImageViewer] = useState(false); + const [viewerSrc, setViewerSrc] = useState(""); + const [viewerNode, setViewerNode] = useState(null); + const [viewerMeta, setViewerMeta] = useState<{ title?: string; width?: number; height?: number }>({}); + const [hasActiveImg, setHasActiveImg] = useState(false); + const imgToolbarRef = useRef(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) => ( + + ); + return ( +
{ + 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, "靠右")} +
+ ); }; 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 ? (
-
- 备注 +
+ 备注 @@ -522,6 +695,188 @@ const MindmapBlockView = ({
) : null; + const imageViewer = showImageViewer ? ( +
setShowImageViewer(false)}> +
+ +
+ {viewerMeta.title || "图片预览"} + {viewerMeta.width && viewerMeta.height ? `${viewerMeta.width} × ${viewerMeta.height}px` : ""} +
+ {viewerMeta.title e.stopPropagation()} + /> +
+
+ ) : null; + + const imgToolbar = renderImgToolbar(); + + const imageModal = showImageModal ? ( +
+
+
+ 插入图片 + +
+
+
+ +
+ + + {imageUrl.startsWith("data:") ? "已选择本地图片" : "未选择文件"} + + { + 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); + }} + /> +
+
+
+ + setImageUrl(e.target.value)} + placeholder="https://example.com/image.png" + /> +
+
+ +
+ + + + +
+
+
+
+ + setImageWidth(e.target.value)} + min={10} + placeholder="260" + /> +
+
+ + setImageHeight(e.target.value)} + min={10} + placeholder="200" + /> +
+
+
+ + setImageTitle(e.target.value)} + placeholder="图片标题" + /> +
+
+ + +
+

可选择本地图片或粘贴 URL,默认尺寸 260×200。

+
+
+ + +
+
+
+ ) : null; + if (fullscreen) { return (
@@ -551,7 +906,10 @@ const MindmapBlockView = ({ {/* @ts-expect-error mindmap type */} + {imgToolbar} {noteModal} + {imageModal} + {imageViewer}
); @@ -587,7 +945,10 @@ const MindmapBlockView = ({ {/* @ts-expect-error mindmap type */} + {imgToolbar} {noteModal} + {imageModal} + {imageViewer}
); diff --git a/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx b/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx index 9d3ac643..4e2f47f7 100644 --- a/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx +++ b/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx @@ -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 = {}; [ "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)} /> +
+ + updateThemeConfig("backgroundImage", e.target.value || "")} + placeholder="可粘贴图片地址" + /> +
+ ({ label: i.name, value: i.value }))} + onChange={(v) => updateThemeConfig("backgroundRepeat", v)} + /> + ({ label: i.name, value: i.value }))} + onChange={(v) => updateThemeConfig("backgroundPosition", v)} + /> + ({ label: i.name, value: i.value }))} + onChange={(v) => updateThemeConfig("backgroundSize", v)} + /> +
+
@@ -512,12 +541,12 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => { + +