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:
SpecialX
2026-07-04 11:46:31 +08:00
parent 124ed97060
commit 5d306dc686
9 changed files with 5 additions and 1719 deletions

View File

@@ -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} />
);
}

View File

@@ -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.dataRecord<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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
);
});
}

View File

@@ -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 };
});
}

View File

@@ -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);
}
// 孤立节点(无边)也要 setNodedagre 会自动排列
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;
}

View File

@@ -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 存入 datafromRfEdges 从 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,
};
});
}