training_data/apps/web/src/components/common/editor/NodeMenu.tsx

271 lines
7.8 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (urlMode === "POSTURL" && lecture?.courseId && lecture?.id)
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;