refactor(lesson-preparation): 删除废弃的 React Flow 画布和字符串锚点文件
- 删除 node-editor.tsx(React Flow 画布) - 删除 nodes/lesson-node.tsx、textbook-content-node.tsx、textbook-segments.tsx、anchor-node-selector.tsx - 删除 lib/anchor-injector.ts(字符串偏移系统) - 删除 lib/rf-mappers.ts(React Flow 映射) - 删除 lib/auto-layout.ts(自动布局) - 重构 lesson-plan-readonly-view.tsx 改用线性视图(LessonPlanMobileView)
This commit is contained in:
@@ -1,29 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
MiniMap,
|
||||
type Node,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
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 { useMediaQuery } from "@/shared/hooks/use-media-query";
|
||||
import { LessonPlanMobileView } from "./lesson-plan-mobile-view";
|
||||
import type { LessonPlanDocument } from "../types";
|
||||
|
||||
const nodeTypes = {
|
||||
lesson: LessonNode,
|
||||
textbook_content: TextbookContentNodeComponent,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
doc: LessonPlanDocument;
|
||||
textbookTitle?: string;
|
||||
@@ -31,111 +10,14 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* 只读课案画布视图(学生/家长/管理员/教研组长使用)。
|
||||
* V4 只读课案视图(学生/家长/管理员/教研组长使用)。
|
||||
*
|
||||
* 复用 React Flow 渲染,但禁用所有编辑交互:
|
||||
* - 节点不可拖动、不可连线
|
||||
* - 可缩放、可平移(便于查看)
|
||||
* - 可点击节点查看详情(通过 onSelectNode 回调)
|
||||
* V4 重构:移除 React Flow 画布,统一使用线性视图(按 order 排序)渲染节点。
|
||||
* 原 V3 的画布只读视图已被 LessonPlanMobileView 的线性卡片列表替代,
|
||||
* 该组件在 V4 中作为统一入口,桌面端和移动端共用同一渲染逻辑。
|
||||
*/
|
||||
export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
// V5-13 P3:小屏设备使用线性移动视图替代画布
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
|
||||
const rfNodes = useMemo(() => toRfNodes(doc.nodes, selectedNodeId), [doc.nodes, selectedNodeId]);
|
||||
const rfEdges = useMemo(
|
||||
() => toRfEdges(doc.edges, selectedNodeId, doc.anchors ?? [], doc.nodes),
|
||||
[doc.edges, doc.anchors, selectedNodeId, doc.nodes],
|
||||
);
|
||||
|
||||
// 为正文节点准备 data(锚点、选中节点、选择回调)
|
||||
const nodesWithData: Node[] = useMemo(() => {
|
||||
return rfNodes.map((n) => {
|
||||
if (n.type === "textbook_content") {
|
||||
return {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
node: doc.nodes.find((nn) => nn.id === n.id),
|
||||
anchors: doc.anchors ?? [],
|
||||
selectedNodeId,
|
||||
onSelectNode: setSelectedNodeId,
|
||||
},
|
||||
};
|
||||
}
|
||||
return n;
|
||||
});
|
||||
}, [rfNodes, doc.nodes, doc.anchors, selectedNodeId]);
|
||||
|
||||
// V5-13 P3:移动端渲染线性视图
|
||||
if (isMobile) {
|
||||
return (
|
||||
<LessonPlanMobileView doc={doc} textbookTitle={textbookTitle} chapterTitle={chapterTitle} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative">
|
||||
{/* 顶部信息条 */}
|
||||
{(textbookTitle || chapterTitle) && (
|
||||
<div className="absolute top-2 left-2 z-10 bg-surface/95 backdrop-blur border border-outline-variant rounded-md px-3 py-1.5 text-xs text-on-surface-variant flex items-center gap-2 shadow-sm">
|
||||
{textbookTitle && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="font-medium">{t("editor.textbookLabel")}</span>
|
||||
{textbookTitle}
|
||||
</span>
|
||||
)}
|
||||
{chapterTitle && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="font-medium">{t("editor.chapterLabel")}</span>
|
||||
{chapterTitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReactFlow
|
||||
nodes={nodesWithData}
|
||||
edges={rfEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={true}
|
||||
panOnDrag={true}
|
||||
zoomOnScroll={true}
|
||||
zoomOnPinch={true}
|
||||
panOnScroll={false}
|
||||
zoomOnDoubleClick={false}
|
||||
selectionOnDrag={false}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
|
||||
<Controls showInteractive={false} />
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
nodeColor={(n) => {
|
||||
// V3 修复:使用 getNodeColor 替代硬编码颜色,与编辑器保持一致
|
||||
const data = n.data;
|
||||
if (!data || typeof data !== "object") return getNodeColor(n.type ?? "");
|
||||
const nodeData = data.node;
|
||||
if (
|
||||
nodeData &&
|
||||
typeof nodeData === "object" &&
|
||||
nodeData !== null &&
|
||||
"type" in nodeData &&
|
||||
typeof nodeData.type === "string"
|
||||
) {
|
||||
return getNodeColor(nodeData.type);
|
||||
}
|
||||
return getNodeColor(n.type ?? "");
|
||||
}}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
<LessonPlanMobileView doc={doc} textbookTitle={textbookTitle} chapterTitle={chapterTitle} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
"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<string, never>;
|
||||
|
||||
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<AnyLessonPlanNode, { type: BlockType }> => 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 (
|
||||
<div
|
||||
className="w-full h-full relative"
|
||||
role="application"
|
||||
aria-label={t("editor.canvasLabel")}
|
||||
// 禁用整个画布的浏览器默认右键菜单
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{doc.nodes.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="text-center text-on-surface-variant">
|
||||
<p className="text-lg font-medium">{t("editor.canvasEmpty")}</p>
|
||||
<p className="text-sm mt-1">{t("editor.canvasEmptyHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ReactFlow
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChangeSync}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={(_, node) => 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}
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={20}
|
||||
size={1}
|
||||
color="#ccc"
|
||||
/>
|
||||
<Controls className="!bg-surface !border-outline-variant" />
|
||||
{/* V5-8:自动布局按钮 */}
|
||||
<Panel position="top-right" className="!m-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAutoLayout}
|
||||
disabled={doc.nodes.length === 0}
|
||||
title={t("editor.autoLayoutHint")}
|
||||
aria-label={t("editor.autoLayout")}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4 mr-1" />
|
||||
{t("editor.autoLayout")}
|
||||
</Button>
|
||||
</Panel>
|
||||
<MiniMap
|
||||
className="!bg-surface !border-outline-variant"
|
||||
nodeColor={(n) => {
|
||||
// V3 修复:从 React Flow 的 Node.data(Record<string, unknown>)安全提取 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";
|
||||
}}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { getNodeColor } from "../../lib/node-summary";
|
||||
|
||||
interface AnchorNodeSelectorProps {
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
anchorableNodes: { id: string; title: string; type: string }[];
|
||||
hasSelectedNode: boolean;
|
||||
onPickNode: (nodeId: string) => void;
|
||||
onCreateNew: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 锚点节点选择器(P1-1 完善)
|
||||
* - 渲染可锚定的教学节点列表(点击即关联到该节点)
|
||||
* - 提供"关联到当前选中节点"快捷项(仅当有选中节点时)
|
||||
* - 提供"创建新节点并关联"选项(触发 onCreateNew 回调)
|
||||
*/
|
||||
export function AnchorNodeSelector({
|
||||
t,
|
||||
anchorableNodes,
|
||||
hasSelectedNode,
|
||||
onPickNode,
|
||||
onCreateNew,
|
||||
}: AnchorNodeSelectorProps) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{hasSelectedNode && (
|
||||
<button
|
||||
className="w-full text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded font-medium"
|
||||
onClick={() => onPickNode("__selected__")}
|
||||
>
|
||||
{t("editor.anchorToSelectedNode")}
|
||||
</button>
|
||||
)}
|
||||
{anchorableNodes.length > 0 && (
|
||||
<>
|
||||
<div className="text-[10px] uppercase tracking-wide text-on-surface-variant/70 px-2 pt-1">
|
||||
{t("editor.selectNodeForAnchor")}
|
||||
</div>
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
{anchorableNodes.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
className="w-full text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded flex items-center gap-2"
|
||||
onClick={() => onPickNode(n.id)}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: getNodeColor(n.type) }}
|
||||
/>
|
||||
<span className="truncate">{n.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="border-t border-outline-variant mt-1 pt-1">
|
||||
<button
|
||||
className="w-full text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded text-primary"
|
||||
onClick={onCreateNew}
|
||||
>
|
||||
+ {t("editor.createNewNode")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import type { LessonPlanNode } from "../../types";
|
||||
import { getNodeColor, getNodeSummary, type NodeSummaryT } from "../../lib/node-summary";
|
||||
|
||||
// P1 修复:类型守卫替代 as 断言,安全从 React Flow NodeProps.data 收窄
|
||||
function isLessonNodeData(data: unknown): data is { node: LessonPlanNode } {
|
||||
if (typeof data !== "object" || data === null) return false;
|
||||
const obj = data as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj.node === "object" &&
|
||||
obj.node !== null &&
|
||||
typeof (obj.node as Record<string, unknown>).type === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export const LessonNode = memo(function LessonNode({
|
||||
data,
|
||||
selected,
|
||||
}: NodeProps) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
// P1 修复:使用类型守卫安全收窄,若数据形状不符则渲染空状态避免运行时崩溃
|
||||
if (!isLessonNodeData(data)) {
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-outline-variant bg-surface p-3 text-xs text-on-surface-variant">
|
||||
{t("editor.unknownBlockType")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const nodeData = data.node;
|
||||
const color = getNodeColor(nodeData.type);
|
||||
// 适配 next-intl 的 t 到 NodeSummaryT 接口
|
||||
const summaryT: NodeSummaryT = (key, values) => t(key, values);
|
||||
const summary = getNodeSummary(nodeData, summaryT);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border-2 bg-surface shadow-md min-w-[200px] max-w-[260px] transition-shadow"
|
||||
style={{
|
||||
borderColor: selected ? "#1976d2" : color,
|
||||
boxShadow: selected ? "0 0 0 2px rgba(25,118,210,0.3)" : undefined,
|
||||
}}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!bg-on-surface !w-3 !h-3 !border-2 !border-surface"
|
||||
/>
|
||||
<div
|
||||
className="px-3 py-2 rounded-t-md text-white text-xs font-medium flex items-center gap-1"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
<span>{t(`blockType.${nodeData.type}`) ?? nodeData.type}</span>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-sm font-medium text-on-surface truncate">
|
||||
{nodeData.title}
|
||||
</div>
|
||||
<div className="text-xs text-on-surface-variant mt-1">
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!bg-on-surface !w-3 !h-3 !border-2 !border-surface"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,471 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo, useRef, useCallback, useState, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { NodeProps } from "@xyflow/react";
|
||||
|
||||
import type { NodeAnchor, TextbookContentNode as TextbookContentNodeModel } from "../../types";
|
||||
import {
|
||||
injectPlaceholders,
|
||||
parseAnchoredText,
|
||||
toCircledNumber,
|
||||
getNextPointIndex,
|
||||
markdownToPlainText,
|
||||
} from "../../lib/anchor-injector";
|
||||
import { getNodeColor } from "../../lib/node-summary";
|
||||
import { AnchorNodeSelector } from "./anchor-node-selector";
|
||||
import { renderSegments } from "./textbook-segments";
|
||||
|
||||
interface TextbookContentNodeProps {
|
||||
node: TextbookContentNodeModel;
|
||||
anchors: NodeAnchor[];
|
||||
selectedNodeId: string | null;
|
||||
/** 可锚定的教学节点列表(用于锚点节点选择器)*/
|
||||
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;
|
||||
/** 创建新节点并锚定 */
|
||||
onCreateNewNode?: (params: {
|
||||
anchorType: "range" | "point";
|
||||
start: number;
|
||||
end?: number;
|
||||
textPreview?: string;
|
||||
}) => void;
|
||||
onSelectNode?: (id: string | null) => void;
|
||||
onResize?: (width: number, height: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫:安全收窄 React Flow NodeProps.data 为 TextbookContentNodeProps
|
||||
* 替代 `as unknown as` 断言,通过结构检查确保数据形状正确
|
||||
*/
|
||||
function isTextbookContentNodePropsData(
|
||||
data: unknown,
|
||||
): data is TextbookContentNodeProps {
|
||||
if (typeof data !== "object" || data === null) return false;
|
||||
const obj = data as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj.node === "object" &&
|
||||
obj.node !== null &&
|
||||
typeof (obj.node as Record<string, unknown>).type === "string" &&
|
||||
Array.isArray(obj.anchors)
|
||||
);
|
||||
}
|
||||
|
||||
export const TextbookContentNode = memo(function TextbookContentNode({
|
||||
data,
|
||||
selected,
|
||||
}: NodeProps) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const props = isTextbookContentNodePropsData(data) ? data : null;
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const resizeHandleRef = useRef<HTMLDivElement>(null);
|
||||
const [showAnchorMenu, setShowAnchorMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
selection: { start: number; end: number; text: string } | null;
|
||||
point: number | null;
|
||||
} | null>(null);
|
||||
// 光标位置指示器(左键点击时显示)
|
||||
const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null);
|
||||
// 拖拽缩放状态
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const resizeStart = useRef<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
|
||||
const node = props?.node;
|
||||
const anchors = useMemo(() => props?.anchors ?? [], [props?.anchors]);
|
||||
const selectedNodeId = props?.selectedNodeId ?? null;
|
||||
const anchorableNodes = props?.anchorableNodes ?? [];
|
||||
|
||||
// 注入锚点标记后的 Markdown
|
||||
const injectedContent = useMemo(() => {
|
||||
if (!node) return "";
|
||||
return injectPlaceholders(node.data.content, anchors);
|
||||
}, [node, anchors]);
|
||||
|
||||
// 解析为段落数组(用于自定义渲染)
|
||||
const segments = useMemo(
|
||||
() => parseAnchoredText(injectedContent),
|
||||
[injectedContent],
|
||||
);
|
||||
|
||||
// 选中节点的激活锚点 ID 集合
|
||||
const activeAnchorIds = useMemo(() => {
|
||||
if (!selectedNodeId) return new Set<string>();
|
||||
return new Set(
|
||||
anchors.filter((a) => a.nodeId === selectedNodeId).map((a) => a.id),
|
||||
);
|
||||
}, [anchors, selectedNodeId]);
|
||||
|
||||
// 获取锚点对应的节点颜色
|
||||
const getAnchorNodeColor = useCallback(
|
||||
(anchorId: string): string => {
|
||||
const anchor = anchors.find((a) => a.id === anchorId);
|
||||
if (!anchor) return "#9e9e9e";
|
||||
return getNodeColor(anchor.nodeId);
|
||||
},
|
||||
[anchors],
|
||||
);
|
||||
|
||||
// 计算选中文本在纯文本中的偏移
|
||||
const computeSelectionOffset = useCallback(
|
||||
(selectedText: string): { start: number; end: number } | null => {
|
||||
if (!node) return null;
|
||||
const plainText = markdownToPlainText(node.data.content);
|
||||
const start = plainText.indexOf(selectedText);
|
||||
if (start >= 0) {
|
||||
return { start, end: start + selectedText.length };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[node],
|
||||
);
|
||||
|
||||
// 计算点击位置在纯文本中的偏移,并返回 caret 的视口坐标(用于精确定位光标指示器)
|
||||
const computePointOffset = useCallback(
|
||||
(clientX: number, clientY: number): { offset: number; rect: DOMRect | null } => {
|
||||
let offset = -1;
|
||||
let rect: DOMRect | null = null;
|
||||
// 优先用 caretPositionFromPoint(标准 API)
|
||||
if (document.caretPositionFromPoint) {
|
||||
const pos = document.caretPositionFromPoint(clientX, clientY);
|
||||
if (pos) {
|
||||
offset = pos.offset;
|
||||
try {
|
||||
const range = document.createRange();
|
||||
range.setStart(pos.offsetNode, pos.offset);
|
||||
range.setEnd(pos.offsetNode, pos.offset);
|
||||
// collapsed range 用 getClientRects 获取 caret 位置
|
||||
const rects = range.getClientRects();
|
||||
rect = rects.length > 0 ? rects[0] : range.getBoundingClientRect();
|
||||
} catch {
|
||||
rect = null;
|
||||
}
|
||||
}
|
||||
} else if (document.caretRangeFromPoint) {
|
||||
// 回退:WebKit 专用 API
|
||||
const range = document.caretRangeFromPoint(clientX, clientY);
|
||||
if (range) {
|
||||
offset = range.startOffset;
|
||||
const rects = range.getClientRects();
|
||||
rect = rects.length > 0 ? rects[0] : range.getBoundingClientRect();
|
||||
}
|
||||
}
|
||||
return { offset, rect };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 右键菜单:在右键位置弹出锚点菜单
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!node) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const selection = window.getSelection();
|
||||
const selectedText = selection && !selection.isCollapsed ? selection.toString() : "";
|
||||
|
||||
if (selectedText) {
|
||||
// 有选中文本:提供区间锚定
|
||||
const offsets = computeSelectionOffset(selectedText);
|
||||
if (!offsets) return;
|
||||
setShowAnchorMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
selection: { ...offsets, text: selectedText },
|
||||
point: null,
|
||||
});
|
||||
} else {
|
||||
// 无选中文本:提供点锚定
|
||||
const { offset } = computePointOffset(e.clientX, e.clientY);
|
||||
if (offset < 0) return;
|
||||
setShowAnchorMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
selection: null,
|
||||
point: offset,
|
||||
});
|
||||
}
|
||||
},
|
||||
[node, computeSelectionOffset, computePointOffset],
|
||||
);
|
||||
|
||||
// 左键点击:显示光标位置指示器(用 caret 实际坐标精确定位)
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!node) return;
|
||||
// 如果有选中文本,不显示光标(让浏览器处理选择)
|
||||
const selection = window.getSelection();
|
||||
if (selection && !selection.isCollapsed) {
|
||||
setCursorPos(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算 caret 位置,优先用 caret 的 rect 坐标,fallback 到鼠标坐标
|
||||
const { offset, rect } = computePointOffset(e.clientX, e.clientY);
|
||||
if (offset < 0) {
|
||||
setCursorPos(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setCursorPos({
|
||||
x: rect && rect.width >= 0 ? rect.left : e.clientX,
|
||||
y: rect && rect.width >= 0 ? rect.top : e.clientY,
|
||||
});
|
||||
},
|
||||
[node, computePointOffset],
|
||||
);
|
||||
|
||||
// 关闭锚点菜单
|
||||
useEffect(() => {
|
||||
if (!showAnchorMenu) return;
|
||||
function handleOutside(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest("[data-anchor-menu]")) {
|
||||
setShowAnchorMenu(null);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleOutside);
|
||||
return () => document.removeEventListener("mousedown", handleOutside);
|
||||
}, [showAnchorMenu]);
|
||||
|
||||
// 光标指示器自动消失
|
||||
useEffect(() => {
|
||||
if (!cursorPos) return;
|
||||
const timer = setTimeout(() => setCursorPos(null), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [cursorPos]);
|
||||
|
||||
// 阻止 React Flow 在正文内容区和缩放手柄上拦截 pointerdown 事件(切实保障文本选择和缩放可用)
|
||||
// nodrag class 只能阻止拖拽,但 React Flow 可能在更上层 preventDefault 阻止文本选择
|
||||
// 使用原生事件监听器 stopPropagation,让 React Flow 完全收不到 pointerdown
|
||||
useEffect(() => {
|
||||
const stopPointer = (e: PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
const contentEl = contentRef.current;
|
||||
const resizeEl = resizeHandleRef.current;
|
||||
contentEl?.addEventListener("pointerdown", stopPointer);
|
||||
resizeEl?.addEventListener("pointerdown", stopPointer);
|
||||
return () => {
|
||||
contentEl?.removeEventListener("pointerdown", stopPointer);
|
||||
resizeEl?.removeEventListener("pointerdown", stopPointer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 拖拽缩放
|
||||
const handleResizeStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
resizeStart.current = { x: e.clientX, y: e.clientY, w: rect.width, h: rect.height };
|
||||
setResizing(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizing || !resizeStart.current || !containerRef.current) return;
|
||||
function handleMove(e: MouseEvent) {
|
||||
if (!resizeStart.current || !containerRef.current) return;
|
||||
const dx = e.clientX - resizeStart.current.x;
|
||||
const dy = e.clientY - resizeStart.current.y;
|
||||
const newW = Math.max(300, resizeStart.current.w + dx);
|
||||
const newH = Math.max(200, resizeStart.current.h + dy);
|
||||
containerRef.current.style.width = `${newW}px`;
|
||||
containerRef.current.style.height = `${newH}px`;
|
||||
}
|
||||
function handleUp() {
|
||||
setResizing(false);
|
||||
resizeStart.current = null;
|
||||
}
|
||||
document.addEventListener("mousemove", handleMove);
|
||||
document.addEventListener("mouseup", handleUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
}, [resizing]);
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-outline-variant bg-surface p-4 text-on-surface-variant">
|
||||
{t("editor.textbookContentMissing")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nextPointNumber = getNextPointIndex(anchors);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="rounded-lg border-2 bg-surface shadow-lg flex flex-col relative"
|
||||
style={{
|
||||
borderColor: selected ? "#1976d2" : "#455a64",
|
||||
boxShadow: selected ? "0 0 0 2px rgba(25,118,210,0.3)" : undefined,
|
||||
width: 520,
|
||||
minWidth: 300,
|
||||
minHeight: 200,
|
||||
}}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div
|
||||
className="px-3 py-2 rounded-t-md text-white text-xs font-medium flex items-center justify-between flex-shrink-0"
|
||||
style={{ backgroundColor: "#455a64" }}
|
||||
>
|
||||
<span>{t("editor.textbookContent")}</span>
|
||||
<span className="text-white/60 text-[10px]">
|
||||
{t("editor.rightClickHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 正文内容 */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
// nodrag class 让 React Flow 跳过拖拽逻辑,允许在正文上选择文本
|
||||
className="px-4 py-3 flex-1 overflow-y-auto text-sm leading-relaxed text-on-surface select-text nodrag"
|
||||
onContextMenu={handleContextMenu}
|
||||
onClick={handleClick}
|
||||
style={{ userSelect: "text", WebkitUserSelect: "text" }}
|
||||
>
|
||||
{node.data.content ? (
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{renderSegments({
|
||||
segments,
|
||||
activeAnchorIds,
|
||||
getAnchorNodeColor,
|
||||
onSelectNode: props?.onSelectNode,
|
||||
anchors,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-on-surface-variant text-sm py-8 text-center">
|
||||
{t("editor.textbookContentEmpty")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 拖拽缩放手柄 */}
|
||||
<div
|
||||
ref={resizeHandleRef}
|
||||
// nodrag class 让 React Flow 跳过拖拽逻辑,允许缩放手柄独立工作
|
||||
className="absolute bottom-0 right-0 w-5 h-5 cursor-nwse-resize bg-surface-container-high border-l-2 border-t-2 border-outline-variant rounded-tl-md flex items-center justify-center hover:bg-surface-container-highest z-10 nodrag"
|
||||
onMouseDown={handleResizeStart}
|
||||
title={t("editor.dragToResize")}
|
||||
>
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" className="text-on-surface-variant">
|
||||
<path d="M0 8 L8 0 M3 8 L8 3 M6 8 L8 6" stroke="currentColor" strokeWidth="1" fill="none" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 光标位置指示器(通过 portal 渲染到 body,避免 React Flow transform 容器影响 fixed 定位)*/}
|
||||
{cursorPos && typeof document !== "undefined" && createPortal(
|
||||
<div
|
||||
className="fixed pointer-events-none z-40"
|
||||
style={{
|
||||
left: cursorPos.x,
|
||||
top: cursorPos.y,
|
||||
width: 2,
|
||||
height: 16,
|
||||
backgroundColor: "#1976d2",
|
||||
animation: "cursor-blink 1s infinite",
|
||||
}}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* 锚点浮动菜单(右键触发,通过 portal 渲染到 body 保证 fixed 定位相对视口)*/}
|
||||
{showAnchorMenu && typeof document !== "undefined" && createPortal(
|
||||
<div
|
||||
data-anchor-menu
|
||||
className="fixed z-50 bg-surface border border-outline-variant rounded-lg shadow-xl p-2 min-w-[220px] max-h-[60vh] overflow-y-auto"
|
||||
style={{
|
||||
left: showAnchorMenu.x,
|
||||
top: showAnchorMenu.y,
|
||||
}}
|
||||
>
|
||||
{showAnchorMenu.selection ? (
|
||||
<div>
|
||||
<div className="text-xs text-on-surface-variant mb-2 px-2">
|
||||
{t("editor.rangeAnchorTitle")}
|
||||
</div>
|
||||
<AnchorNodeSelector
|
||||
t={t}
|
||||
anchorableNodes={anchorableNodes}
|
||||
hasSelectedNode={!!selectedNodeId}
|
||||
onPickNode={(nodeId) => {
|
||||
if (props?.onAddRangeAnchor && showAnchorMenu.selection) {
|
||||
props.onAddRangeAnchor({
|
||||
nodeId,
|
||||
start: showAnchorMenu.selection.start,
|
||||
end: showAnchorMenu.selection.end,
|
||||
textPreview: showAnchorMenu.selection.text,
|
||||
});
|
||||
}
|
||||
setShowAnchorMenu(null);
|
||||
}}
|
||||
onCreateNew={() => {
|
||||
if (props?.onCreateNewNode && showAnchorMenu.selection) {
|
||||
props.onCreateNewNode({
|
||||
anchorType: "range",
|
||||
start: showAnchorMenu.selection.start,
|
||||
end: showAnchorMenu.selection.end,
|
||||
textPreview: showAnchorMenu.selection.text,
|
||||
});
|
||||
}
|
||||
setShowAnchorMenu(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : showAnchorMenu.point !== null ? (
|
||||
<div>
|
||||
<div className="text-xs text-on-surface-variant mb-2 px-2">
|
||||
{t("editor.pointAnchorTitle", { number: toCircledNumber(nextPointNumber) })}
|
||||
</div>
|
||||
<AnchorNodeSelector
|
||||
t={t}
|
||||
anchorableNodes={anchorableNodes}
|
||||
hasSelectedNode={!!selectedNodeId}
|
||||
onPickNode={(nodeId) => {
|
||||
if (props?.onAddPointAnchor && showAnchorMenu.point !== null) {
|
||||
props.onAddPointAnchor({
|
||||
nodeId,
|
||||
start: showAnchorMenu.point,
|
||||
});
|
||||
}
|
||||
setShowAnchorMenu(null);
|
||||
}}
|
||||
onCreateNew={() => {
|
||||
if (props?.onCreateNewNode && showAnchorMenu.point !== null) {
|
||||
props.onCreateNewNode({
|
||||
anchorType: "point",
|
||||
start: showAnchorMenu.point,
|
||||
});
|
||||
}
|
||||
setShowAnchorMenu(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { ReactNode, CSSProperties, KeyboardEvent } from "react";
|
||||
|
||||
import type { NodeAnchor } from "../../types";
|
||||
import {
|
||||
parseAnchoredText,
|
||||
toCircledNumber,
|
||||
} from "../../lib/anchor-injector";
|
||||
|
||||
interface RenderSegmentsParams {
|
||||
segments: ReturnType<typeof parseAnchoredText>;
|
||||
activeAnchorIds: Set<string>;
|
||||
getAnchorNodeColor: (anchorId: string) => string;
|
||||
onSelectNode?: (id: string | null) => void;
|
||||
anchors?: NodeAnchor[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 键盘激活回调:Enter / Space 触发与点击同等的选中逻辑(V4 P0-3 修复 a11y)。
|
||||
*/
|
||||
function handleAnchorKeyDown(
|
||||
e: KeyboardEvent<HTMLSpanElement>,
|
||||
anchor: NodeAnchor | undefined,
|
||||
onSelectNode: ((id: string | null) => void) | undefined,
|
||||
): void {
|
||||
if (!anchor || !onSelectNode) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelectNode(anchor.nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染锚点段落数组(简化版:直接遍历 segments,不使用 ReactMarkdown)
|
||||
* 解决问题 7:避免每个段落重复渲染整个文档内容
|
||||
*/
|
||||
export function renderSegments({
|
||||
segments,
|
||||
activeAnchorIds,
|
||||
getAnchorNodeColor,
|
||||
onSelectNode,
|
||||
anchors,
|
||||
}: RenderSegmentsParams): ReactNode {
|
||||
return segments.map((seg, idx) => {
|
||||
if (seg.type === "text") {
|
||||
return <span key={idx}>{seg.content}</span>;
|
||||
}
|
||||
if (seg.type === "anchor-range") {
|
||||
const isActive = seg.anchorId ? activeAnchorIds.has(seg.anchorId) : false;
|
||||
const color = seg.anchorId ? getAnchorNodeColor(seg.anchorId) : "#9e9e9e";
|
||||
const anchor = anchors?.find((a) => a.id === seg.anchorId);
|
||||
// V4 P0-3 修复:锚点 span 添加 role/tabIndex/onKeyDown/aria-label,支持键盘导航与读屏
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
role={anchor ? "button" : undefined}
|
||||
tabIndex={anchor ? 0 : undefined}
|
||||
aria-label={anchor ? `跳转到关联节点 ${anchor.nodeId}` : undefined}
|
||||
aria-pressed={anchor ? isActive : undefined}
|
||||
className={`range-anchor ${isActive ? "active" : ""}`}
|
||||
// CSS 自定义属性需要断言,因为 TS 的 CSSProperties 不包含 --* 变量
|
||||
style={
|
||||
{
|
||||
backgroundColor: color,
|
||||
"--node-color": color,
|
||||
} as CSSProperties
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (anchor && onSelectNode) {
|
||||
onSelectNode(anchor.nodeId);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => handleAnchorKeyDown(e, anchor, onSelectNode)}
|
||||
>
|
||||
{seg.content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// point anchor
|
||||
const isActive = seg.anchorId ? activeAnchorIds.has(seg.anchorId) : false;
|
||||
const color = seg.anchorId ? getAnchorNodeColor(seg.anchorId) : "#9e9e9e";
|
||||
const anchor = anchors?.find((a) => a.id === seg.anchorId);
|
||||
const pointIndex = anchor
|
||||
? (anchors?.filter((a) => a.type === "point").indexOf(anchor) ?? -1) + 1
|
||||
: 1;
|
||||
// V4 P0-3 修复:point anchor 同样补齐 a11y 属性
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
role={anchor ? "button" : undefined}
|
||||
tabIndex={anchor ? 0 : undefined}
|
||||
aria-label={anchor ? `跳转到关联节点 ${anchor.nodeId}` : undefined}
|
||||
aria-pressed={anchor ? isActive : undefined}
|
||||
className={`point-anchor ${isActive ? "active" : ""}`}
|
||||
// CSS 自定义属性需要断言,因为 TS 的 CSSProperties 不包含 --* 变量
|
||||
style={
|
||||
{
|
||||
backgroundColor: color,
|
||||
"--node-color": color,
|
||||
} as CSSProperties
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (anchor && onSelectNode) {
|
||||
onSelectNode(anchor.nodeId);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => handleAnchorKeyDown(e, anchor, onSelectNode)}
|
||||
>
|
||||
{toCircledNumber(pointIndex ?? 1)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
import type { NodeAnchor } from "../types";
|
||||
|
||||
/**
|
||||
* 锚点注入算法:将锚点信息注入到 Markdown 文本中,生成带标记的纯文本。
|
||||
*
|
||||
* 策略:
|
||||
* - 由于 Markdown 渲染后是 HTML,纯文本偏移量无法直接对应 DOM 节点
|
||||
* - 简化方案:将 Markdown 视为纯文本(去除 markdown 语法符号),在纯文本上做偏移注入
|
||||
* - 注入特殊标记符号(如 ①②③ 或 [anchor:id]),由 ReactMarkdown 的 components 自定义渲染
|
||||
*
|
||||
* 对于 range 锚定:在 [start, end] 范围包裹 [[anchor:id]]...[[/anchor]] 标记
|
||||
* 对于 point 锚定:在 start 位置插入 [[point:id]] 标记
|
||||
*
|
||||
* 渲染时由 textbook-content-node.tsx 的 components 自定义解析这些标记。
|
||||
*/
|
||||
|
||||
// 标记格式:[[anchor:id]]range text[[/anchor]] 或 [[point:id]]
|
||||
const ANCHOR_RANGE_START = (id: string) => `[[anchor:${id}]]`;
|
||||
const ANCHOR_RANGE_END = `[[/anchor]]`;
|
||||
const ANCHOR_POINT = (id: string) => `[[point:${id}]]`;
|
||||
|
||||
/**
|
||||
* 将 Markdown 文本简化为纯文本(去除常见 markdown 语法符号)。
|
||||
* 仅用于锚点偏移计算,不影响实际渲染。
|
||||
*/
|
||||
export function markdownToPlainText(markdown: string): string {
|
||||
return markdown
|
||||
// 去除标题标记
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
// 去除强调符号
|
||||
.replace(/\*\*(.+?)\*\*/g, "$1")
|
||||
.replace(/\*(.+?)\*/g, "$1")
|
||||
.replace(/__(.+?)__/g, "$1")
|
||||
.replace(/_(.+?)_/g, "$1")
|
||||
// 去除链接,保留文本
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
// 去除图片
|
||||
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
|
||||
// 去除代码块
|
||||
.replace(/```[\s\S]*?```/g, "")
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
// 去除引用标记
|
||||
.replace(/^\s*>\s+/gm, "")
|
||||
// 去除列表标记
|
||||
.replace(/^[\s]*[-*+]\s+/gm, "")
|
||||
.replace(/^[\s]*\d+\.\s+/gm, "")
|
||||
// 去除水平分割线
|
||||
.replace(/^---+$/gm, "")
|
||||
// 去除 HTML 标签
|
||||
.replace(/<[^>]+>/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Markdown 文本中注入锚点标记。
|
||||
*
|
||||
* 注意:偏移量基于纯文本(markdownToPlainText 的输出)。
|
||||
* 由于 Markdown 语法符号的存在,纯文本偏移与 Markdown 原文偏移不一致。
|
||||
* 此函数通过构建偏移映射,将纯文本偏移转换为 Markdown 原文偏移。
|
||||
*
|
||||
* @param markdown 原始 Markdown 文本
|
||||
* @param anchors 锚点列表
|
||||
* @returns 注入标记后的 Markdown 文本
|
||||
*/
|
||||
export function injectPlaceholders(
|
||||
markdown: string,
|
||||
anchors: NodeAnchor[],
|
||||
): string {
|
||||
if (anchors.length === 0) return markdown;
|
||||
|
||||
// 构建偏移映射:plainText[i] → markdown 原文位置
|
||||
const { plainToMd } = buildOffsetMap(markdown);
|
||||
|
||||
// 过滤失效锚点,按 markdown 偏移排序(倒序注入,避免偏移变化)
|
||||
const validAnchors = anchors
|
||||
.filter((a) => !a.invalid && a.start >= 0)
|
||||
.map((a) => {
|
||||
const mdStart = plainToMd.get(a.start) ?? a.start;
|
||||
const mdEnd = a.end !== undefined
|
||||
? plainToMd.get(a.end) ?? a.end
|
||||
: undefined;
|
||||
return { ...a, mdStart, mdEnd };
|
||||
})
|
||||
.sort((a, b) => b.mdStart - a.mdStart);
|
||||
|
||||
let result = markdown;
|
||||
for (const anchor of validAnchors) {
|
||||
if (anchor.type === "range" && anchor.mdEnd !== undefined && anchor.mdEnd > anchor.mdStart) {
|
||||
// 范围锚定:包裹标记
|
||||
const before = result.slice(0, anchor.mdStart);
|
||||
const middle = result.slice(anchor.mdStart, anchor.mdEnd);
|
||||
const after = result.slice(anchor.mdEnd);
|
||||
result = before + ANCHOR_RANGE_START(anchor.id) + middle + ANCHOR_RANGE_END + after;
|
||||
} else if (anchor.type === "point") {
|
||||
// 点锚定:插入标记
|
||||
const before = result.slice(0, anchor.mdStart);
|
||||
const after = result.slice(anchor.mdStart);
|
||||
result = before + ANCHOR_POINT(anchor.id) + after;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Markdown → 纯文本的偏移映射。
|
||||
* 返回 plainToMd(纯文本位置 → Markdown 原文位置)。
|
||||
*/
|
||||
function buildOffsetMap(markdown: string): {
|
||||
plainToMd: Map<number, number>;
|
||||
} {
|
||||
const plainToMd = new Map<number, number>();
|
||||
let mdIdx = 0;
|
||||
let plainIdx = 0;
|
||||
|
||||
// 简化映射:逐字符遍历 Markdown,跳过被去除的字符
|
||||
// 这里采用与 markdownToPlainText 一致的简化逻辑
|
||||
// V4 P2-5 修复:统一 regex 标志为 gm,与 markdownToPlainText 保持一致
|
||||
const skipPatterns: RegExp[] = [
|
||||
/^#{1,6}\s+/gm,
|
||||
/^\s*[-*+]\s+/gm,
|
||||
/^\s*\d+\.\s+/gm,
|
||||
/^\s*>\s+/gm,
|
||||
/^---+$/gm,
|
||||
];
|
||||
|
||||
while (mdIdx < markdown.length) {
|
||||
// 检查是否处于需要跳过的模式
|
||||
let skipped = false;
|
||||
for (const pattern of skipPatterns) {
|
||||
const rest = markdown.slice(mdIdx);
|
||||
const match = rest.match(pattern);
|
||||
if (match && match.index === 0) {
|
||||
mdIdx += match[0].length;
|
||||
skipped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (skipped) continue;
|
||||
|
||||
// 处理行内标记(**、*、`、_)
|
||||
const ch = markdown[mdIdx];
|
||||
if (ch === "*" || ch === "_" || ch === "`") {
|
||||
// 跳过成对的标记符号
|
||||
if (markdown[mdIdx + 1] === ch) {
|
||||
mdIdx += 2; // 跳过 ** 或 __ 或 ``
|
||||
continue;
|
||||
}
|
||||
mdIdx += 1; // 跳过单个 * 或 _ 或 `
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理 HTML 标签
|
||||
if (ch === "<") {
|
||||
const closeIdx = markdown.indexOf(">", mdIdx);
|
||||
if (closeIdx !== -1) {
|
||||
mdIdx = closeIdx + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理链接 [text](url)
|
||||
if (ch === "[") {
|
||||
const closeBracket = markdown.indexOf("]", mdIdx);
|
||||
if (closeBracket !== -1 && markdown[closeBracket + 1] === "(") {
|
||||
const closeParen = markdown.indexOf(")", closeBracket + 2);
|
||||
if (closeParen !== -1) {
|
||||
// 链接文本部分映射到纯文本
|
||||
const linkText = markdown.slice(mdIdx + 1, closeBracket);
|
||||
for (const _ of linkText) {
|
||||
plainToMd.set(plainIdx, mdIdx + 1 + plainIdx);
|
||||
plainIdx++;
|
||||
}
|
||||
mdIdx = closeParen + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 普通字符:建立映射
|
||||
plainToMd.set(plainIdx, mdIdx);
|
||||
plainIdx++;
|
||||
mdIdx++;
|
||||
}
|
||||
|
||||
return { plainToMd };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析注入标记后的文本,提取锚点段。
|
||||
* 用于 ReactMarkdown 自定义渲染。
|
||||
*/
|
||||
export interface ParsedSegment {
|
||||
type: "text" | "anchor-range" | "anchor-point";
|
||||
content: string;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
export function parseAnchoredText(text: string): ParsedSegment[] {
|
||||
const segments: ParsedSegment[] = [];
|
||||
// 匹配 [[anchor:id]]...[[/anchor]] 或 [[point:id]]
|
||||
const pattern = /\[\[(anchor|point):([^\]]+)\]\](?:([\s\S]*?)\[\[\/anchor\]\])?/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
// 前面的普通文本
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
type: "text",
|
||||
content: text.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
|
||||
if (match[1] === "anchor") {
|
||||
segments.push({
|
||||
type: "anchor-range",
|
||||
content: match[3] ?? "",
|
||||
anchorId: match[2],
|
||||
});
|
||||
} else {
|
||||
// point
|
||||
segments.push({
|
||||
type: "anchor-point",
|
||||
content: "",
|
||||
anchorId: match[2],
|
||||
});
|
||||
}
|
||||
|
||||
lastIndex = pattern.lastIndex;
|
||||
}
|
||||
|
||||
// 剩余文本
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({
|
||||
type: "text",
|
||||
content: text.slice(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据锚点 ID 获取对应的节点颜色。
|
||||
*/
|
||||
export function getAnchorColor(
|
||||
anchorId: string,
|
||||
anchors: NodeAnchor[],
|
||||
getNodeColorFn: (nodeId: string) => string,
|
||||
): string {
|
||||
const anchor = anchors.find((a) => a.id === anchorId);
|
||||
if (!anchor) return "#9e9e9e";
|
||||
return getNodeColorFn(anchor.nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成下一个点锚定的序号(①②③...)。
|
||||
* 使用 anchors 中 point 类型的数量 + 1。
|
||||
*/
|
||||
export function getNextPointIndex(anchors: NodeAnchor[]): number {
|
||||
const pointCount = anchors.filter((a) => a.type === "point").length;
|
||||
return pointCount + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数字转换为带圈数字(①②③...⑨⑩等)。
|
||||
* 超过 20 时回退为 [1] [2] 格式。
|
||||
*/
|
||||
export function toCircledNumber(n: number): string {
|
||||
if (n < 1) return "";
|
||||
if (n > 20) return `[${n}]`;
|
||||
// Unicode 带圈数字 ①=U+2460
|
||||
return String.fromCharCode(0x2460 + n - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文内容变更后,尝试用 textPreview 重新定位锚点。
|
||||
* 无法定位的标记为 invalid。
|
||||
*/
|
||||
export function relocateAnchors(
|
||||
anchors: NodeAnchor[],
|
||||
newPlainText: string,
|
||||
): NodeAnchor[] {
|
||||
return anchors.map((anchor) => {
|
||||
if (anchor.type === "range" && anchor.textPreview) {
|
||||
const newStart = newPlainText.indexOf(anchor.textPreview);
|
||||
if (newStart >= 0) {
|
||||
return {
|
||||
...anchor,
|
||||
start: newStart,
|
||||
end: newStart + anchor.textPreview.length,
|
||||
invalid: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
// point 锚定无法自动重定位(位置语义已变)
|
||||
if (anchor.type === "point") {
|
||||
// 如果 start 仍在范围内,保留
|
||||
if (anchor.start >= 0 && anchor.start <= newPlainText.length) {
|
||||
return { ...anchor, invalid: false };
|
||||
}
|
||||
}
|
||||
return { ...anchor, invalid: true };
|
||||
});
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* V5-8:画布自动布局
|
||||
*
|
||||
* 使用 dagre 计算 DAG 布局,将教学节点按流程关系自动排列。
|
||||
* 仅对可拖动的教学节点(非正文节点)布局,正文节点位置保持不变。
|
||||
*/
|
||||
import dagre from "@dagrejs/dagre";
|
||||
import type {
|
||||
AnyLessonPlanNode,
|
||||
AnyLessonPlanEdge,
|
||||
} from "../types";
|
||||
|
||||
export interface AutoLayoutOptions {
|
||||
/** 布局方向:TB(上→下)/ LR(左→右),默认 TB */
|
||||
direction?: "TB" | "LR";
|
||||
/** 节点宽度,默认 240 */
|
||||
nodeWidth?: number;
|
||||
/** 节点高度,默认 120 */
|
||||
nodeHeight?: number;
|
||||
/** 节点水平间距,默认 40 */
|
||||
rankSep?: number;
|
||||
/** 节点垂直间距,默认 60 */
|
||||
nodeSep?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算自动布局,返回每个节点的新位置(含原始位置作为兜底)
|
||||
*/
|
||||
export function computeAutoLayout(
|
||||
nodes: AnyLessonPlanNode[],
|
||||
edges: AnyLessonPlanEdge[],
|
||||
options: AutoLayoutOptions = {},
|
||||
): Map<string, { x: number; y: number }> {
|
||||
const {
|
||||
direction = "TB",
|
||||
nodeWidth = 240,
|
||||
nodeHeight = 120,
|
||||
rankSep = 60,
|
||||
nodeSep = 40,
|
||||
} = options;
|
||||
|
||||
const result = new Map<string, { x: number; y: number }>();
|
||||
|
||||
if (nodes.length === 0) return result;
|
||||
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep });
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
// 仅对教学节点布局(正文节点固定位置)
|
||||
const layoutableNodes = nodes.filter((n) => n.type !== "textbook_content");
|
||||
const layoutableIds = new Set(layoutableNodes.map((n) => n.id));
|
||||
|
||||
for (const node of layoutableNodes) {
|
||||
g.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
||||
}
|
||||
|
||||
// 仅添加两端都可布局的 flow 边
|
||||
for (const edge of edges) {
|
||||
if (edge.type !== "flow") continue;
|
||||
if (!layoutableIds.has(edge.source) || !layoutableIds.has(edge.target)) continue;
|
||||
g.setEdge(edge.source, edge.target);
|
||||
}
|
||||
|
||||
// 孤立节点(无边)也要 setNode,dagre 会自动排列
|
||||
dagre.layout(g);
|
||||
|
||||
for (const node of layoutableNodes) {
|
||||
const laid = g.node(node.id);
|
||||
if (laid) {
|
||||
// dagre 返回中心点,React Flow 使用左上角,需减去半宽/半高
|
||||
result.set(node.id, {
|
||||
x: laid.x - nodeWidth / 2,
|
||||
y: laid.y - nodeHeight / 2,
|
||||
});
|
||||
} else {
|
||||
result.set(node.id, node.position);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
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<string>();
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user