import type { Node, Edge } from "@xyflow/react"; import type { AnyLessonPlanEdge, AnyLessonPlanNode, NodeAnchor, } from "../types"; import { getNodeColor } from "./node-summary"; /** * 纯函数:根据 nodeId 从节点列表中查找节点类型。 * P0-8 修复:toRfEdges 需要节点类型来获取颜色,而非传入 nodeId。 */ function getNodeTypeById( nodes: AnyLessonPlanNode[], nodeId: string, ): string { const node = nodes.find((n) => n.id === nodeId); return node?.type ?? "rich_text"; } /** * 纯函数:将课案 nodes/edges 映射为 React Flow 格式。 * 从 node-editor.tsx 抽取,便于单元测试。 * * v3 升级: * - 区分教学节点(type="lesson")和正文节点(type="textbook_content") * - 正文节点传入 anchors/selectedNodeId/onAddAnchor 等回调 * - 边区分 anchor/flow 类型,应用不同透明度 */ export interface ToRfNodesContext { anchors: NodeAnchor[]; selectedNodeId: string | null; /** 可锚定的教学节点列表(P1-1:用于节点选择器)*/ anchorableNodes?: { id: string; title: string; type: string }[]; onAddRangeAnchor?: (params: { nodeId: string; start: number; end: number; textPreview: string; }) => void; onAddPointAnchor?: (params: { nodeId: string; start: number; }) => void; /** 创建新节点并锚定(P1-1)*/ onCreateNewNode?: (params: { anchorType: "range" | "point"; start: number; end?: number; textPreview?: string; }) => void; onSelectNode?: (id: string | null) => void; } export function toRfNodes( nodes: AnyLessonPlanNode[], selectedNodeId: string | null, ctx?: ToRfNodesContext, ): Node[] { // 当有选中节点时,收集所有与选中节点相关的节点 ID(通过锚点关联) const relatedNodeIds = new Set(); if (selectedNodeId && ctx?.anchors) { for (const a of ctx.anchors) { if (a.nodeId === selectedNodeId) { relatedNodeIds.add(a.nodeId); } } // 正文节点始终相关(因为锚点在正文上) const textbookNode = nodes.find((n) => n.type === "textbook_content"); if (textbookNode) relatedNodeIds.add(textbookNode.id); relatedNodeIds.add(selectedNodeId); } return nodes.map((n) => { // 正文节点:n.type === "textbook_content" 已收窄为 TextbookContentNode if (n.type === "textbook_content") { const tbNode = n; const isDimmed = selectedNodeId !== null && !relatedNodeIds.has(tbNode.id); return { id: tbNode.id, type: "textbook_content", position: tbNode.position, data: { node: tbNode, anchors: ctx?.anchors ?? [], selectedNodeId, anchorableNodes: ctx?.anchorableNodes ?? [], onAddRangeAnchor: ctx?.onAddRangeAnchor, onAddPointAnchor: ctx?.onAddPointAnchor, onCreateNewNode: ctx?.onCreateNewNode, onSelectNode: ctx?.onSelectNode, } as Record, selected: tbNode.id === selectedNodeId, draggable: false, style: isDimmed ? { opacity: 0.3 } : undefined, }; } // 教学节点:textbook_content 分支已上方 return,此处 n 已收窄为 LessonPlanNode const lessonNode = n; const isDimmed = selectedNodeId !== null && !relatedNodeIds.has(lessonNode.id); return { id: lessonNode.id, type: "lesson", position: lessonNode.position, data: { node: lessonNode } as Record, selected: lessonNode.id === selectedNodeId, style: isDimmed ? { opacity: 0.3 } : undefined, }; }); } export function toRfEdges( edges: AnyLessonPlanEdge[], selectedNodeId: string | null, anchors: NodeAnchor[], nodes: AnyLessonPlanNode[] = [], ): Edge[] { return edges.map((e) => { if (e.type === "anchor") { // 锚点边:默认 40% 透明度,选中关联节点时 100% const anchor = anchors.find((a) => a.id === e.anchorId); const isActive = anchor && anchor.nodeId === selectedNodeId; // P0-8 修复:传入节点 type 而非 nodeId,使颜色映射正确生效 const nodeType = anchor ? getNodeTypeById(nodes, anchor.nodeId) : "rich_text"; const strokeColor = getNodeColor(nodeType); return { ...e, animated: isActive, className: isActive ? "anchor-edge active" : "anchor-edge", // P1-3 修复:将 anchorId 存入 data,fromRfEdges 从 data 读取 data: { anchorId: e.anchorId }, style: { stroke: strokeColor, strokeWidth: isActive ? 3 : 2, opacity: isActive ? 1 : 0.4, }, }; } // 流程边 const isDimmed = selectedNodeId !== null && e.source !== selectedNodeId && e.target !== selectedNodeId; return { ...e, animated: !isDimmed, style: { stroke: "#1976d2", strokeWidth: 2, opacity: isDimmed ? 0.2 : 1, }, }; }); } /** * 将 React Flow edges 转回课案 edges 格式。 */ export function fromRfEdges( rfEdges: Edge[], ): AnyLessonPlanEdge[] { return rfEdges.map((e) => { const base = { id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle ?? null, targetHandle: e.targetHandle ?? null, }; // P1-3 修复:优先从 data.anchorId 读取,回退到 className 判断 const dataAnchorId = (e.data as { anchorId?: string } | undefined)?.anchorId; if (dataAnchorId || e.className?.includes("anchor-edge")) { return { ...base, type: "anchor" as const, anchorId: dataAnchorId ?? e.id, }; } return { ...base, type: "flow" as const, }; }); }