import chatStyles from "@/app/components/chat.module.scss"; import homeStyles from "@/app/components/home.module.scss"; import { MindSiderBar } from "./mind-siderBar"; import MindElixir, { type Options, type MindElixirData, NodeObj, } from "mind-elixir"; import { WindowContent } from "@/app/components/home"; import { useMobileScreen } from "@/app/utils"; import { IconButton } from "../button"; import Locale from "@/app/locales"; import { Path } from "@/app/constant"; import { useNavigate, useLocation } from "react-router-dom"; import clsx from "clsx"; import { getClientConfig } from "@/app/config/client"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { useAppConfig } from "@/app/store"; import ReturnIcon from "@/app/icons/return.svg"; import MinIcon from "@/app/icons/min.svg"; import MaxIcon from "@/app/icons/max.svg"; import SDIcon from "@/app/icons/sd.svg"; import BoldIcon from "@/app/icons/bold.svg"; import FontColorIcon from "@/app/icons/fontColor.svg"; import FontBgIcon from "@/app/icons/fontBg.svg"; import CenterIcon from "@/app/icons/center.svg"; import MindIcon from "@/app/icons/mind.svg"; import LeftIcon from "@/app/icons/leftMind.svg"; import RightIcon from "@/app/icons/rightMind.svg"; import LineIcon from "@/app/icons/line.svg"; import InitSideIcon from "@/app/icons/initSide.svg"; import { useChatStore, useMindMapStore } from "@/app/store"; import { message, Select, ColorPicker, Button, Space, Slider, Popover, } from "antd"; import type { ColorPickerProps, GetProp } from "antd"; import { INITIAL_DATA, LOADING_DATA, FONT_SIZE_OPTIONS, EXPORT_OPTIONS, useColor, } from "./mind-utils"; type Color = Extract< GetProp<ColorPickerProps, "value">, string | { cleared: any } >; const ScaleControl = ({ value, onChange, }: { value: number; onChange: (v: number) => void; }) => ( <Slider vertical value={value} onChange={onChange} style={{ height: "100px" }} min={35} max={200} tooltip={{ formatter: (v) => `${v}%` }} /> ); export function MindPage() { const isMobileScreen = useMobileScreen(); const navigate = useNavigate(); const clientConfig = useMemo(() => getClientConfig(), []); const showMaxIcon = !isMobileScreen && !clientConfig?.isApp; const config = useAppConfig(); const scrollRef = useRef<HTMLDivElement>(null); const isMind = location.pathname === Path.Mind; const [isLoading, setIsLoading] = useState(false); //加载 辅助 const containerRef = useRef<HTMLDivElement>(null); //思维导图容器 const mindInstance = useRef<InstanceType<typeof MindElixir> | null>(null); //思维导图实例 const chatStore = useChatStore(); const { newMessages, content } = useMindMapStore.getState(); //聊天页面跳转时要生成的信息 const query = useLocation(); let { msg } = query.state || {}; const [data, setData] = useState<MindElixirData>(INITIAL_DATA); //思维导图数据 const [fontColor, setFontColor] = useColor("#1677ff"); //字体颜色 const [nodeBgColor, setNodeBgColor] = useColor("#1677ff"); //节点背景颜色 const [lineColor, setLinrColor] = useColor("#1677ff"); //分支颜色 const [selectedNode, setSelectedNode] = useState<NodeObj | null>(null); const [scaleValue, setScaleValue] = useState(100); //如果是在聊天页面跳转过来时需要发起请求 const fetchData = async () => { if (!msg) return; if (!content) return; setIsLoading(true); try { const response = await chatStore.sendContext(newMessages, "gpt-4o-mini"); const cleanedContent = response.replace(/^```json|```$/g, ""); const parsedData: MindElixirData = JSON.parse(cleanedContent); // 增强校验逻辑 if ( !parsedData?.nodeData?.id || !Array.isArray(parsedData.nodeData.children) ) { throw new Error("数据结构不完整"); } setData(parsedData); navigate(Path.Mind, { replace: true, state: { msg: null } }); } catch (error) { message.error("请求失败,请重试"); } finally { setIsLoading(false); // 确保关闭加载状态 } }; useEffect(() => { // 确保容器元素已挂载 if (!containerRef.current) return; // 初始化配置项 const options: Options = { el: containerRef.current, locale: "zh_CN", draggable: true, contextMenu: true, toolBar: false, nodeMenu: false, }; // 创建实例 mindInstance.current = new MindElixir(options); mindInstance.current.init(data); const el = mindInstance.current?.container.querySelector("me-root"); //添加鼠标移入中心主题时鼠标变手的样式 const style = document.createElement("style"); // 添加 CSS 样式 style.textContent = ` me-root:hover { cursor: pointer; } `; // 将样式插入到文档中 document.head.appendChild(style); const handleContainerClick = (e: MouseEvent) => { const target = e.target as HTMLElement; // 检查点击的目标是否是某个特定的元素(比如 me-root) if (target.closest("me-root")) { // 获取 me-nodes 父元素 const parentElement = target.closest("me-nodes"); // 确保父元素存在并且是 HTMLElement 类型 if (parentElement && parentElement instanceof HTMLElement) { // 鼠标按下时开始跟随 const onMouseDown = (e: MouseEvent) => { // 记录鼠标按下的初始位置和父元素的初始位置 const initialX = e.clientX; const initialY = e.clientY; const initialElementX = parentElement.offsetLeft; const initialElementY = parentElement.offsetTop; // 鼠标移动时更新父元素位置 const onMouseMove = (moveEvent: MouseEvent) => { const deltaX = moveEvent.clientX - initialX; const deltaY = moveEvent.clientY - initialY; parentElement.style.position = "absolute"; parentElement.style.left = `${initialElementX + deltaX}px`; parentElement.style.top = `${initialElementY + deltaY}px`; }; // 鼠标松开时停止跟随 const onMouseUp = () => { window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); }; // 添加事件监听器 window.addEventListener("mousemove", onMouseMove); window.addEventListener("mouseup", onMouseUp); }; // 添加鼠标按下事件监听器 target.addEventListener("mousedown", onMouseDown); } } }; //注册点击事件 mindInstance.current.container.addEventListener( "click", handleContainerClick, ); fetchData(); //设置选择的节点用于调节样式 mindInstance.current?.bus.addListener("selectNode", (node) => { setSelectedNode(node); }); return () => { mindInstance.current?.container.removeEventListener( "click", handleContainerClick, ); if (mindInstance.current) { mindInstance.current.destroy(); mindInstance.current = null; } }; }, []); //data更新思维导图更新数据 useEffect(() => { mindInstance.current?.refresh(data); }, [data]); useEffect(() => { if (isLoading) { mindInstance.current?.refresh(LOADING_DATA); } }, [isLoading]); useEffect(() => { if (selectedNode) { // 更新字体颜色 updateNodeStyle({ color: fontColor }); } }, [fontColor]); useEffect(() => { if (selectedNode) { // 更新节点背景颜色 updateNodeStyle({ background: nodeBgColor }); } }, [nodeBgColor]); // 导出函数 const handleDownload = async (type: "png" | "svg") => { try { const blob = type === "png" ? await mindInstance.current?.exportPng(false) : await mindInstance.current?.exportSvg(false); if (!blob) return; const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${data.nodeData.topic}.${type}`; a.click(); URL.revokeObjectURL(url); } catch (error) { console.error("导出失败:", error); message.error("导出失败,请重试"); } }; //思维导图缩放函数 const handleScaleChange = (value: number) => { setScaleValue(value); mindInstance.current?.scale(value / 100); }; //更新样式 const updateNodeStyle = (style: Partial<NodeObj["style"]>) => { if (selectedNode) { mindInstance.current?.reshapeNode(MindElixir.E(selectedNode.id), { style: { ...selectedNode.style, ...style }, }); } }; //更新分支颜色 const updateBranchColor = (value: string) => { if (selectedNode) { mindInstance.current?.reshapeNode(MindElixir.E(selectedNode.id), { branchColor: value, }); } }; const mindType = ( <div> <Button icon={<LeftIcon />} onClick={() => { mindInstance.current?.initLeft(); }} /> <Button icon={<RightIcon />} onClick={() => { mindInstance.current?.initRight(); }} /> <Button icon={ <InitSideIcon onClick={() => { mindInstance.current?.initSide(); }} /> } /> </div> ); //工具栏 const renderToolbar = () => ( <Space> <Popover content={ <ScaleControl value={scaleValue} onChange={handleScaleChange} /> } > <Button>{scaleValue}%</Button> </Popover> <Popover content={mindType}> <Button icon={<MindIcon />}></Button> </Popover> <Select options={EXPORT_OPTIONS} onSelect={handleDownload} placeholder="导出" style={{ width: 120 }} /> <Select options={FONT_SIZE_OPTIONS} onSelect={(v) => updateNodeStyle({ fontSize: v })} placeholder="字号" style={{ width: 100 }} disabled={!selectedNode} /> <Button icon={<CenterIcon />} onClick={() => mindInstance.current?.toCenter()} /> <Button icon={<BoldIcon />} onClick={() => updateNodeStyle({ fontWeight: selectedNode?.style?.fontWeight === "bold" ? "normal" : "bold", }) } disabled={!selectedNode} /> <ColorPicker value={fontColor} onChange={(value) => { setFontColor(value || "#000000"); // 处理可能的空值 updateNodeStyle({ color: value?.toHexString() || "#000000" }); }} disabled={!selectedNode} showText > <Button icon={<FontColorIcon />} /> </ColorPicker> <ColorPicker value={nodeBgColor} onChange={(value) => { setNodeBgColor(value || "#ffffff"); updateNodeStyle({ background: value?.toHexString() || "#ffffff" }); }} disabled={!selectedNode} showText > <Button icon={<FontBgIcon />} /> </ColorPicker> <ColorPicker value={lineColor} onChange={(value) => { setLinrColor(value || "#ffffff"); updateBranchColor(value?.toHexString() || "#ffffff"); }} disabled={!selectedNode} showText > <Button icon={<LineIcon />} /> </ColorPicker> </Space> ); return ( <> <MindSiderBar className={clsx({ [homeStyles["sidebar-show"]]: isMind })} setData={setData} isLoading={isLoading} setIsLoading={setIsLoading} /> <WindowContent> <div className={chatStyles.chat} key={"1"}> <div className="window-header" data-tauri-drag-region> {isMobileScreen && ( <div className="window-actions"> <div className={"window-action-button"}> <IconButton icon={<ReturnIcon />} bordered title={Locale.Chat.Actions.ChatList} onClick={() => navigate(Path.BgRemoval)} /> </div> </div> )} <div className={clsx( "window-header-title", chatStyles["chat-body-title"], )} > <div className={`window-header-main-title`}>生成思维导图</div> </div> <div className={chatStyles["chat-message-actions"]}> <div className={chatStyles["chat-input-actions"]}> {renderToolbar()} </div> </div> <div className="window-actions"> {showMaxIcon && ( <div className="window-action-button"> <IconButton aria={Locale.Chat.Actions.FullScreen} icon={config.tightBorder ? <MinIcon /> : <MaxIcon />} bordered onClick={() => { config.update( (config) => (config.tightBorder = !config.tightBorder), ); }} /> </div> )} {isMobileScreen && <SDIcon width={50} height={50} />} </div> </div> <div className={chatStyles["chat-body"]} ref={scrollRef}> <div ref={containerRef} style={{ width: "100%", height: "100%" }} /> </div> </div> </WindowContent> </> ); }