"use client"; import { useCallback, useMemo } from "react"; import { useTranslations } from "next-intl"; import { ReactFlow, Background, Controls, MiniMap, type Node, type Edge, type NodeChange, type EdgeChange, type Connection, applyEdgeChanges, BackgroundVariant, Panel, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { LayoutGrid } from "lucide-react"; import { Button } from "@/shared/components/ui/button"; import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor"; import { LessonNode } from "./nodes/lesson-node"; import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node"; import { toRfNodes, toRfEdges } from "../lib/rf-mappers"; import { getNodeColor } from "../lib/node-summary"; import type { AnyLessonPlanNode, BlockType } from "../types"; const nodeTypes = { lesson: LessonNode, textbook_content: TextbookContentNodeComponent, }; // NodeEditor 只负责画布交互,内容编辑由 NodeEditPanel 处理 type Props = Record; export function NodeEditor({}: Props) { const t = useTranslations("lessonPreparation"); const { doc, selectedNodeId, updateNodePosition, removeNode, connect, selectNode, setEdges, addAnchor, addNode, autoLayout, } = useLessonPlanEditor(); // V5-8:自动布局按钮 const handleAutoLayout = useCallback(() => { autoLayout("TB"); }, [autoLayout]); // P1-1:构建可锚定的教学节点列表(排除正文节点) const anchorableNodes = useMemo( () => doc.nodes .filter((n): n is Extract => n.type !== "textbook_content") .map((n) => ({ id: n.id, title: n.title, type: n.type })), [doc.nodes], ); // 锚点添加回调(正文节点使用) const handleAddRangeAnchor = useCallback( (params: { nodeId: string; start: number; end: number; textPreview: string }) => { // __selected__ 表示使用当前选中节点 const actualNodeId = params.nodeId === "__selected__" ? selectedNodeId ?? "" : params.nodeId; if (!actualNodeId) return; addAnchor({ nodeId: actualNodeId, type: "range", start: params.start, end: params.end, textPreview: params.textPreview, }); }, [addAnchor, selectedNodeId], ); const handleAddPointAnchor = useCallback( (params: { nodeId: string; start: number }) => { const actualNodeId = params.nodeId === "__selected__" ? selectedNodeId ?? "" : params.nodeId; if (!actualNodeId) return; addAnchor({ nodeId: actualNodeId, type: "point", start: params.start, }); }, [addAnchor, selectedNodeId], ); // P1-1:创建新节点并锚定 const handleCreateNewNode = useCallback( (params: { anchorType: "range" | "point"; start: number; end?: number; textPreview?: string; }) => { // 默认创建 rich_text 节点(最通用的类型),用户可后续切换 const newNodeId = addNode("rich_text", undefined, t("blockType.rich_text")); addAnchor({ nodeId: newNodeId, type: params.anchorType, start: params.start, ...(params.end !== undefined ? { end: params.end } : {}), ...(params.textPreview ? { textPreview: params.textPreview } : {}), }); }, [addNode, addAnchor, t], ); // 使用纯函数映射 nodes/edges const rfNodes: Node[] = useMemo( () => toRfNodes(doc.nodes, selectedNodeId, { anchors: doc.anchors, selectedNodeId, anchorableNodes, onAddRangeAnchor: handleAddRangeAnchor, onAddPointAnchor: handleAddPointAnchor, onCreateNewNode: handleCreateNewNode, onSelectNode: selectNode, }), [doc.nodes, doc.anchors, selectedNodeId, anchorableNodes, handleAddRangeAnchor, handleAddPointAnchor, handleCreateNewNode, selectNode], ); const rfEdges: Edge[] = useMemo( () => toRfEdges(doc.edges, selectedNodeId, doc.anchors, doc.nodes), [doc.edges, selectedNodeId, doc.anchors, doc.nodes], ); const onNodesChange = useCallback( (changes: NodeChange[]) => { changes.forEach((change) => { if (change.type === "position" && change.position) { // 实时拖动:每次 position 变化都更新(不再等待 dragging=false) // 但仅在节点正在被拖动或拖动结束时更新 updateNodePosition(change.id, change.position); } else if (change.type === "remove") { removeNode(change.id); } else if (change.type === "select") { selectNode(change.selected ? change.id : null); } }); }, [updateNodePosition, removeNode, selectNode], ); const onConnect = useCallback( (conn: Connection) => { if (conn.source && conn.target) { connect(conn.source, conn.target); } }, [connect], ); // 同步 edges 变化(如拖拽重连) const onEdgesChangeSync = useCallback( (changes: EdgeChange[]) => { // 简单处理:删除时调用 disconnect const nextEdges = applyEdgeChanges(changes, rfEdges); const ourEdges = nextEdges.map((e) => { // 保留原有的 type 信息 const original = doc.edges.find((oe) => oe.id === e.id); if (original?.type === "anchor") { return { id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle ?? null, targetHandle: e.targetHandle ?? null, type: "anchor" as const, anchorId: original.anchorId, }; } return { id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle ?? null, targetHandle: e.targetHandle ?? null, type: "flow" as const, }; }); setEdges(ourEdges); }, [rfEdges, setEdges, doc.edges], ); return (
e.preventDefault()} > {doc.nodes.length === 0 && (

{t("editor.canvasEmpty")}

{t("editor.canvasEmptyHint")}

)} selectNode(node.id)} onPaneClick={() => selectNode(null)} fitView fitViewOptions={{ padding: 0.2, maxZoom: 1.2 }} nodesFocusable nodesDraggable edgesFocusable elementsSelectable panOnDrag zoomOnPinch zoomOnDoubleClick={false} selectionOnDrag={false} deleteKeyCode={["Backspace", "Delete"]} multiSelectionKeyCode={["Shift", "Meta", "Control"]} defaultEdgeOptions={{ animated: true, style: { stroke: "#1976d2", strokeWidth: 2 }, }} proOptions={{ hideAttribution: true }} className="bg-surface-container-low" onlyRenderVisibleElements minZoom={0.2} maxZoom={2.5} elevateNodesOnSelect={false} > {/* V5-8:自动布局按钮 */} { // V3 修复:从 React Flow 的 Node.data(Record)安全提取 node 字段 // 使用类型守卫替代 as 断言 const data = n.data; if (!data || typeof data !== "object") return "#9e9e9e"; const nodeData = data.node; if ( nodeData && typeof nodeData === "object" && nodeData !== null && "type" in nodeData && typeof nodeData.type === "string" ) { return getNodeColor(nodeData.type); } return "#9e9e9e"; }} />
); }