274 lines
7.8 KiB
TypeScript
Executable File
274 lines
7.8 KiB
TypeScript
Executable File
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||
import { Input, Button, ColorPicker, Select } from "antd";
|
||
import {
|
||
FontSizeOutlined,
|
||
BoldOutlined,
|
||
LinkOutlined,
|
||
GlobalOutlined,
|
||
SwapOutlined,
|
||
} from "@ant-design/icons";
|
||
import type { MindElixirInstance, NodeObj } from "mind-elixir";
|
||
import PostSelect from "../../models/post/PostSelect/PostSelect";
|
||
import { Lecture, PostType } from "@nice/common";
|
||
import { xmindColorPresets } from "./constant";
|
||
import { api } from "@nice/client";
|
||
import { env } from "@web/src/env";
|
||
|
||
interface NodeMenuProps {
|
||
mind: MindElixirInstance;
|
||
}
|
||
|
||
//管理节点样式状态
|
||
const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [selectedFontColor, setSelectedFontColor] = useState<string>("");
|
||
const [selectedBgColor, setSelectedBgColor] = useState<string>("");
|
||
const [selectedSize, setSelectedSize] = useState<string>("");
|
||
const [isBold, setIsBold] = useState(false);
|
||
|
||
const [urlMode, setUrlMode] = useState<"URL" | "POSTURL">("POSTURL");
|
||
const [url, setUrl] = useState<string>("");
|
||
const [postId, setPostId] = useState<string>("");
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const { data: lecture, isLoading }: { data: Lecture; isLoading: boolean } =
|
||
api.post.findFirst.useQuery(
|
||
{
|
||
where: { id: postId },
|
||
},
|
||
{ enabled: !!postId }
|
||
);
|
||
useEffect(() => {
|
||
{
|
||
if(lecture?.courseId && lecture?.id){
|
||
if (urlMode === "POSTURL"){
|
||
setUrl(`/course/${lecture?.courseId}/detail/${lecture?.id}`);
|
||
}
|
||
mind.reshapeNode(mind.currentNode, {
|
||
hyperLink: `/course/${lecture?.courseId}/detail/${lecture?.id}`,
|
||
});
|
||
}
|
||
}
|
||
}, [postId, lecture, isLoading, urlMode]);
|
||
|
||
//监听思维导图节点选择事件,更新节点菜单状态
|
||
useEffect(() => {
|
||
const handleSelectNode = (nodeObj: NodeObj) => {
|
||
setIsOpen(true);
|
||
const style = nodeObj.style || {};
|
||
setSelectedFontColor(style.color || "");
|
||
setSelectedBgColor(style.background || "");
|
||
setSelectedSize(style.fontSize || "24");
|
||
setIsBold(style.fontWeight === "bold");
|
||
setUrl(nodeObj.hyperLink || "");
|
||
};
|
||
const handleUnselectNode = () => {
|
||
setIsOpen(false);
|
||
};
|
||
mind.bus.addListener("selectNode", handleSelectNode);
|
||
mind.bus.addListener("unselectNode", handleUnselectNode);
|
||
}, [mind]);
|
||
|
||
useEffect(() => {
|
||
const handleSelectNode = (nodeObj: NodeObj) => {
|
||
setIsOpen(true);
|
||
const style = nodeObj.style || {};
|
||
setSelectedFontColor(style.color || "");
|
||
setSelectedBgColor(style.background || "");
|
||
setSelectedSize(style.fontSize || "24");
|
||
setIsBold(style.fontWeight === "bold");
|
||
setUrl(nodeObj.hyperLink || "");
|
||
};
|
||
const handleUnselectNode = () => {
|
||
setIsOpen(false);
|
||
};
|
||
mind.bus.addListener("selectNode", handleSelectNode);
|
||
mind.bus.addListener("unselectNode", handleUnselectNode);
|
||
}, [mind]);
|
||
|
||
useEffect(() => {
|
||
if (containerRef.current && mind.container) {
|
||
mind.container.appendChild(containerRef.current);
|
||
}
|
||
}, [mind.container]);
|
||
|
||
const handleColorChange = (type: "font" | "background", color: string) => {
|
||
if (type === "font") {
|
||
setSelectedFontColor(color);
|
||
} else {
|
||
setSelectedBgColor(color);
|
||
}
|
||
const patch = { style: {} as any };
|
||
if (type === "font") {
|
||
patch.style.color = color;
|
||
} else {
|
||
patch.style.background = color;
|
||
}
|
||
mind.reshapeNode(mind.currentNode, patch);
|
||
};
|
||
|
||
const handleSizeChange = (size: string) => {
|
||
setSelectedSize(size);
|
||
mind.reshapeNode(mind.currentNode, { style: { fontSize: size } });
|
||
};
|
||
|
||
const handleBoldToggle = () => {
|
||
const fontWeight = isBold ? "" : "bold";
|
||
setIsBold(!isBold);
|
||
mind.reshapeNode(mind.currentNode, { style: { fontWeight } });
|
||
};
|
||
|
||
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const value = e.target.value;
|
||
setUrl(value);
|
||
mind.reshapeNode(mind.currentNode, {
|
||
hyperLink: value,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className={`node-menu-container absolute right-2 top-2 rounded-lg bg-slate-200 shadow-xl ring-2 ring-white transition-all duration-300 ${
|
||
isOpen
|
||
? "opacity-100 translate-y-0"
|
||
: "opacity-0 translate-y-4 pointer-events-none"
|
||
}`}
|
||
ref={containerRef}>
|
||
<div className="p-5 space-y-6">
|
||
{/* Font Size Selector */}
|
||
<div className="space-y-2">
|
||
<h3 className="text-sm font-medium text-gray-600">
|
||
文字样式
|
||
</h3>
|
||
<div className="flex gap-3 items-center justify-between">
|
||
<Select
|
||
value={selectedSize}
|
||
onChange={handleSizeChange}
|
||
prefix={<FontSizeOutlined className="mr-2" />}
|
||
className="w-1/2"
|
||
options={[
|
||
{ value: "12", label: "12" },
|
||
{ value: "14", label: "14" },
|
||
{ value: "16", label: "16" },
|
||
{ value: "18", label: "18" },
|
||
{ value: "20", label: "20" },
|
||
{ value: "24", label: "24" },
|
||
{ value: "28", label: "28" },
|
||
{ value: "32", label: "32" },
|
||
]}
|
||
/>
|
||
<Button
|
||
type={isBold ? "primary" : "default"}
|
||
onClick={handleBoldToggle}
|
||
className="w-1/2"
|
||
icon={<BoldOutlined />}>
|
||
加粗
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Color Picker */}
|
||
<div className="space-y-3">
|
||
<h3 className="text-sm font-medium text-gray-600">
|
||
颜色设置
|
||
</h3>
|
||
|
||
{/* Font Color Picker */}
|
||
<div className="space-y-2">
|
||
<h4 className="text-xs font-medium text-gray-500">
|
||
文字颜色
|
||
</h4>
|
||
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
|
||
{xmindColorPresets.map((color) => (
|
||
<div
|
||
key={`font-${color}`}
|
||
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${
|
||
selectedFontColor === color
|
||
? "outline-blue-500"
|
||
: "outline-transparent"
|
||
}`}
|
||
style={{ backgroundColor: color }}
|
||
onClick={() => {
|
||
handleColorChange("font", color);
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Background Color Picker */}
|
||
<div className="space-y-2">
|
||
<h4 className="text-xs font-medium text-gray-500">
|
||
背景颜色
|
||
</h4>
|
||
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
|
||
{xmindColorPresets.map((color) => (
|
||
<div
|
||
key={`bg-${color}`}
|
||
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${
|
||
selectedBgColor === color
|
||
? "outline-blue-500"
|
||
: "outline-transparent"
|
||
}`}
|
||
style={{ backgroundColor: color }}
|
||
onClick={() => {
|
||
handleColorChange("background", color);
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||
{urlMode === "URL" ? "关联链接" : "关联课时"}
|
||
<Button
|
||
type="text"
|
||
className=" hover:bg-gray-400 active:bg-gray-300 rounded-md text-gray-600 border transition-colors"
|
||
size="small"
|
||
icon={<SwapOutlined />}
|
||
onClick={() =>
|
||
setUrlMode((prev) =>
|
||
prev === "POSTURL" ? "URL" : "POSTURL"
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
{urlMode === "POSTURL" ? (
|
||
<PostSelect
|
||
onChange={(value) => {
|
||
if (typeof value === "string") {
|
||
setPostId(value);
|
||
}
|
||
}}
|
||
params={{
|
||
where: {
|
||
type: PostType.LECTURE,
|
||
},
|
||
}}
|
||
/>
|
||
) : (
|
||
<Input
|
||
placeholder="例如:https://example.com"
|
||
value={url}
|
||
onChange={handleUrlChange}
|
||
addonBefore={<LinkOutlined />}
|
||
/>
|
||
)}
|
||
|
||
{urlMode === "URL" &&
|
||
url &&
|
||
!/^(https?:\/\/\S+|\/|\.\/|\.\.\/)?\S+$/.test(url) && (
|
||
<p className="text-xs text-red-500">
|
||
请输入有效的URL地址
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default NodeMenu;
|