"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, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; 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 } 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, updateTextbookContent, } = useLessonPlanEditor(); // 锚点添加回调(正文节点使用) const handleAddRangeAnchor = useCallback( (params: { nodeId: string; start: number; end: number; textPreview: string }) => { // 如果 nodeId 是 __selected__,使用当前选中节点 // 如果是 __new__,提示用户先创建节点 const actualNodeId = params.nodeId === "__selected__" ? selectedNodeId ?? "" : params.nodeId; if (!actualNodeId || actualNodeId === "__new__") { // 简化:不自动创建新节点,提示用户先选中或创建 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 || actualNodeId === "__new__") { return; } addAnchor({ nodeId: actualNodeId, type: "point", start: params.start, }); }, [addAnchor, selectedNodeId], ); const handleZoomChange = useCallback( (zoom: number) => { updateTextbookContent({ zoom }); }, [updateTextbookContent], ); // 使用纯函数映射 nodes/edges const rfNodes: Node[] = useMemo( () => toRfNodes(doc.nodes, selectedNodeId, { anchors: doc.anchors, selectedNodeId, onAddRangeAnchor: handleAddRangeAnchor, onAddPointAnchor: handleAddPointAnchor, onSelectNode: selectNode, onZoomChange: handleZoomChange, }), [doc.nodes, doc.anchors, selectedNodeId, handleAddRangeAnchor, handleAddPointAnchor, selectNode, handleZoomChange], ); const rfEdges: Edge[] = useMemo( () => toRfEdges(doc.edges, selectedNodeId, doc.anchors), [doc.edges, selectedNodeId, doc.anchors], ); 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 (
{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 deleteKeyCode={["Backspace", "Delete"]} multiSelectionKeyCode={["Shift", "Meta", "Control"]} defaultEdgeOptions={{ animated: true, style: { stroke: "#1976d2", strokeWidth: 2 }, }} proOptions={{ hideAttribution: true }} className="bg-surface-container-low" > { const nodeData = (n.data as { node?: AnyLessonPlanNode }).node; if (!nodeData) return "#9e9e9e"; return getNodeColor(nodeData.type); }} />
); }