feat(lesson-preparation): add anchor canvas design, new blocks, and textbook content node

- Add anchor injector for canvas-based anchor positioning

- Add new block components: blackboard, homework, import, key-point, new-teaching, objective, summary

- Add textbook content node for React Flow canvas

- Update actions (kp, publish, main), data-access (templates, versions, main)

- Update editor, node-editor, block-renderer, and picker components

- Update schema, types, hooks, and lib utilities (document-migration, node-summary, rf-mappers)
This commit is contained in:
SpecialX
2026-06-23 17:37:19 +08:00
parent 1fcef5c3aa
commit 2197e68069
34 changed files with 3190 additions and 402 deletions

View File

@@ -0,0 +1,304 @@
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+/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 一致的简化逻辑
const skipPatterns: RegExp[] = [
/^#{1,6}\s+/m,
/^\s*[-*+]\s+/m,
/^\s*\d+\.\s+/m,
/^\s*>\s+/m,
/^---+$/m,
];
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,10 +1,19 @@
import { createId } from "@paralleldrive/cuid2";
import type {
AnchorEdge,
AnyLessonPlanEdge,
AnyLessonPlanNode,
BlockData,
BlockType,
FlowEdge,
LessonPlanDocument,
LessonPlanDocumentV1,
LessonPlanDocumentV2,
LessonPlanEdge,
LessonPlanNode,
NodeAnchor,
TemplateBlockSkeleton,
TextbookContentNode,
} from "../types";
/**
@@ -12,8 +21,39 @@ import type {
* 从 data-access.ts 抽取,便于单元测试。
*/
// ---- 默认数据生成器:为每种 BlockType 提供初始 data ----
export function defaultDataForType(type: BlockType): BlockData {
switch (type) {
case "objective":
return { objectives: [] };
case "key_point":
return { keyPoints: [] };
case "import":
return { method: "question", prompt: "", durationMin: 5 };
case "new_teaching":
return { teachingPoints: [] };
case "summary":
return { summaryPoints: [], homeworkPreview: "" };
case "homework":
return { assignments: [] };
case "blackboard":
return { layout: "text", content: "", knowledgePointIds: [] };
case "reflection":
return { reflection: [] };
case "exercise":
return { items: [], purpose: "class_practice", knowledgePointIds: [] };
case "text_study":
return { sourceText: "", annotations: [], knowledgePointIds: [] };
case "rich_text":
case "consolidation":
default:
// consolidation 暂复用富文本结构(向后兼容旧模板)
return { html: "", knowledgePointIds: [] };
}
}
// ---- v1 → v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges ----
export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocument {
export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocumentV2 {
const nodes: LessonPlanNode[] = doc.blocks.map((b, i) => ({
...b,
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
@@ -29,8 +69,63 @@ export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocument {
return { version: 2, nodes, edges };
}
// ---- v2 → v3 迁移:注入正文节点 + 锚点数组 + 边类型 ----
export function migrateV2ToV3(
doc: LessonPlanDocumentV2,
chapterId?: string | null,
chapterContent?: string | null,
): LessonPlanDocument {
const textbookContentNodeId = createId();
const textbookNode: TextbookContentNode = {
id: textbookContentNodeId,
type: "textbook_content",
title: "textbook_content",
data: {
chapterId: chapterId ?? "",
content: chapterContent ?? "",
zoom: 1,
},
order: -1,
position: { x: 400, y: 200 },
draggable: false,
};
// 旧 edges 转为 flow 类型
const flowEdges: FlowEdge[] = doc.edges.map((e) => ({
...e,
type: "flow" as const,
}));
return {
version: 3,
textbookContentNodeId,
nodes: [textbookNode, ...doc.nodes],
edges: flowEdges,
anchors: [],
};
}
// ---- 类型守卫:判断是否为 v3 文档 ----
function isV3Document(content: unknown): content is LessonPlanDocument {
if (!content || typeof content !== "object") return false;
const c = content as {
version?: unknown;
textbookContentNodeId?: unknown;
nodes?: unknown;
edges?: unknown;
anchors?: unknown;
};
return (
c.version === 3 &&
typeof c.textbookContentNodeId === "string" &&
Array.isArray(c.nodes) &&
Array.isArray(c.edges) &&
Array.isArray(c.anchors)
);
}
// ---- 类型守卫:判断是否为 v2 文档 ----
function isV2Document(content: unknown): content is LessonPlanDocument {
function isV2Document(content: unknown): content is LessonPlanDocumentV2 {
if (!content || typeof content !== "object") return false;
const c = content as { version?: unknown; nodes?: unknown; edges?: unknown };
return (
@@ -47,40 +142,188 @@ function isV1Document(content: unknown): content is LessonPlanDocumentV1 {
return c.version === 1 && Array.isArray(c.blocks);
}
// ---- 规范化:确保 content 是 v2 格式(兼容旧数据)----
// ---- 规范化:确保 content 是 v3 格式(兼容 v1/v2 旧数据)----
export function normalizeDocument(
content: unknown,
chapterId?: string | null,
chapterContent?: string | null,
): LessonPlanDocument {
if (isV2Document(content)) return content;
if (isV1Document(content)) return migrateV1ToV2(content);
// 空文档
return { version: 2, nodes: [], edges: [] };
if (isV3Document(content)) return content;
if (isV2Document(content)) {
return migrateV2ToV3(content, chapterId, chapterContent);
}
if (isV1Document(content)) {
return migrateV2ToV3(migrateV1ToV2(content), chapterId, chapterContent);
}
// 空文档:创建一个无正文的 v3
const textbookContentNodeId = createId();
const textbookNode: TextbookContentNode = {
id: textbookContentNodeId,
type: "textbook_content",
title: "textbook_content",
data: { chapterId: chapterId ?? "", content: chapterContent ?? "", zoom: 1 },
order: -1,
position: { x: 400, y: 200 },
draggable: false,
};
return {
version: 3,
textbookContentNodeId,
nodes: [textbookNode],
edges: [],
anchors: [],
};
}
// ---- 模板初始化:根据骨架生成初始 contentv2----
// ---- 模板初始化:根据骨架生成初始 contentv3无正文节点----
// 注意:此函数不创建正文节点,调用方应使用 buildDefaultSkeleton 创建完整 v3 文档
export function buildInitialContent(
blocks: TemplateBlockSkeleton[],
): LessonPlanDocument {
const textbookContentNodeId = createId();
const textbookNode: TextbookContentNode = {
id: textbookContentNodeId,
type: "textbook_content",
title: "textbook_content",
data: { chapterId: "", content: "", zoom: 1 },
order: -1,
position: { x: 400, y: 200 },
draggable: false,
};
const nodes: LessonPlanNode[] = blocks.map((b, i) => ({
id: createId(),
type: b.type,
title: b.title,
data:
b.type === "exercise"
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
: b.type === "text_study"
? { sourceText: "", annotations: [], knowledgePointIds: [] }
: { html: "", knowledgePointIds: [] },
data: defaultDataForType(b.type),
order: i,
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
}));
const edges: LessonPlanEdge[] = [];
const edges: FlowEdge[] = [];
for (let i = 0; i < nodes.length - 1; i++) {
edges.push({
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
source: nodes[i].id,
target: nodes[i + 1].id,
type: "flow",
});
}
return { version: 2, nodes, edges };
return {
version: 3,
textbookContentNodeId,
nodes: [textbookNode, ...nodes],
edges,
anchors: [],
};
}
// ---- 默认骨架10 节点 + 1 正文节点(创建课案时使用)----
export function buildDefaultSkeleton(
chapterId: string,
chapterContent: string,
translateTitle?: (key: string) => string,
): LessonPlanDocument {
const textbookContentNodeId = createId();
const textbookNode: TextbookContentNode = {
id: textbookContentNodeId,
type: "textbook_content",
title: "textbook_content",
data: { chapterId, content: chapterContent, zoom: 1 },
order: -1,
position: { x: 400, y: 200 },
draggable: false,
};
// 默认 10 节点骨架(标题使用 i18n 键 blockType.${type}
const skeleton: { type: BlockType; position: { x: number; y: number } }[] = [
{ type: "objective", position: { x: 80, y: 80 } },
{ type: "key_point", position: { x: 80, y: 200 } },
{ type: "import", position: { x: 80, y: 320 } },
{ type: "text_study", position: { x: 80, y: 440 } },
{ type: "new_teaching", position: { x: 720, y: 80 } },
{ type: "exercise", position: { x: 720, y: 200 } },
{ type: "summary", position: { x: 720, y: 320 } },
{ type: "homework", position: { x: 80, y: 560 } },
{ type: "blackboard", position: { x: 720, y: 440 } },
{ type: "reflection", position: { x: 720, y: 560 } },
];
const nodes: LessonPlanNode[] = skeleton.map((s, i) => ({
id: createId(),
type: s.type,
title: translateTitle
? translateTitle(`blockType.${s.type}`)
: `blockType.${s.type}`,
data: defaultDataForType(s.type),
order: i,
position: s.position,
}));
// 默认流程连线:导入→文本研习→新授→练习→小结
const flowPairs: [number, number][] = [
[2, 3], // import → text_study
[3, 4], // text_study → new_teaching
[4, 5], // new_teaching → exercise
[5, 6], // exercise → summary
];
const edges: FlowEdge[] = flowPairs.map(([from, to]) => ({
id: `e_${nodes[from].id}_${nodes[to].id}`,
source: nodes[from].id,
target: nodes[to].id,
type: "flow",
}));
return {
version: 3,
textbookContentNodeId,
nodes: [textbookNode, ...nodes],
edges,
anchors: [],
};
}
// ---- 工具函数:判断节点是否为正文节点 ----
export function isTextbookContentNode(
node: AnyLessonPlanNode,
): node is TextbookContentNode {
return node.type === "textbook_content";
}
// ---- 工具函数:判断边是否为锚点边 ----
export function isAnchorEdge(
edge: AnyLessonPlanEdge,
): edge is AnchorEdge {
return edge.type === "anchor";
}
// ---- 工具函数:获取节点的关联锚点 ----
export function getAnchorsForNode(
anchors: NodeAnchor[],
nodeId: string,
): NodeAnchor[] {
return anchors.filter((a) => a.nodeId === nodeId);
}
// ---- 工具函数:根据选中节点获取激活的锚点 ID 集合 ----
export function getActiveAnchorIds(
anchors: NodeAnchor[],
selectedNodeId: string | null,
): Set<string> {
if (!selectedNodeId) return new Set();
return new Set(
anchors.filter((a) => a.nodeId === selectedNodeId).map((a) => a.id),
);
}
// ---- 工具函数:根据锚点 ID 获取对应的边 ----
export function getAnchorEdges(
edges: AnyLessonPlanEdge[],
anchorIds: Set<string>,
): AnchorEdge[] {
return edges.filter(
(e): e is AnchorEdge =>
e.type === "anchor" && anchorIds.has(e.anchorId),
);
}

View File

@@ -1,4 +1,4 @@
import type { LessonPlanNode } from "../types";
import type { LessonPlanNode, TextbookContentNode } from "../types";
/**
* 节点摘要翻译函数接口。
@@ -6,7 +6,17 @@ import type { LessonPlanNode } from "../types";
* values 类型对齐 next-intl 的 TranslationValuesstring | number | Date
*/
export interface NodeSummaryT {
(key: "editor.questionCount" | "editor.charCount" | "editor.nodeSummaryEmpty", values?: Record<string, string | number | Date>): string;
(
key:
| "editor.questionCount"
| "editor.charCount"
| "editor.nodeSummaryEmpty"
| "editor.itemCount"
| "editor.pointCount"
| "editor.assignmentCount"
| "editor.durationMin",
values?: Record<string, string | number | Date>,
): string;
}
/**
@@ -16,17 +26,69 @@ export interface NodeSummaryT {
*/
export function getNodeSummary(node: LessonPlanNode, t: NodeSummaryT): string {
const data = node.data as {
// 富文本类
html?: string;
// 文本研习
sourceText?: string;
annotations?: unknown[];
// 练习
items?: unknown[];
// 教学目标
objectives?: unknown[];
// 重难点
keyPoints?: unknown[];
// 导入
durationMin?: number;
prompt?: string;
// 新授
teachingPoints?: unknown[];
// 小结
summaryPoints?: unknown[];
// 作业
assignments?: unknown[];
// 板书
content?: string;
// 反思
reflection?: unknown[];
// 知识点
knowledgePointIds?: string[];
};
// 按类型优先级提取摘要
if (data.items !== undefined) {
return t("editor.questionCount", { count: data.items.length });
}
if (data.objectives !== undefined) {
return t("editor.itemCount", { count: data.objectives.length });
}
if (data.keyPoints !== undefined) {
return t("editor.itemCount", { count: data.keyPoints.length });
}
if (data.teachingPoints !== undefined) {
return t("editor.pointCount", { count: data.teachingPoints.length });
}
if (data.summaryPoints !== undefined) {
return t("editor.itemCount", { count: data.summaryPoints.length });
}
if (data.assignments !== undefined) {
return t("editor.assignmentCount", { count: data.assignments.length });
}
if (data.reflection !== undefined) {
return t("editor.itemCount", { count: data.reflection.length });
}
if (data.durationMin !== undefined) {
return t("editor.durationMin", { count: data.durationMin });
}
if (data.annotations !== undefined && data.sourceText !== undefined) {
return t("editor.charCount", { count: data.sourceText.length });
}
if (data.sourceText !== undefined && data.sourceText) {
return t("editor.charCount", { count: data.sourceText.length });
}
if (data.content !== undefined && data.content) {
const text = data.content.replace(/<[^>]+>/g, "").trim();
return text.slice(0, 40) || t("editor.nodeSummaryEmpty");
}
if (data.html) {
// 去标签后取前 40 字
const text = data.html.replace(/<[^>]+>/g, "").trim();
@@ -35,6 +97,17 @@ export function getNodeSummary(node: LessonPlanNode, t: NodeSummaryT): string {
return t("editor.nodeSummaryEmpty");
}
/**
* 纯函数:获取正文节点摘要。
*/
export function getTextbookContentSummary(
node: TextbookContentNode,
t: NodeSummaryT,
): string {
if (!node.data.content) return t("editor.nodeSummaryEmpty");
return t("editor.charCount", { count: node.data.content.length });
}
/**
* 节点类型 → 图标颜色Material Design 色板)。
* 供 lesson-node 和 minimap 复用。
@@ -52,6 +125,7 @@ export const NODE_COLORS: Record<string, string> = {
exercise: "#e91e63",
rich_text: "#9e9e9e",
reflection: "#cddc39",
textbook_content: "#455a64",
};
export function getNodeColor(type: string): string {

View File

@@ -1,30 +1,113 @@
import type { Node, Edge } from "@xyflow/react";
import type { LessonPlanNode, LessonPlanEdge } from "../types";
import type {
AnyLessonPlanEdge,
AnyLessonPlanNode,
LessonPlanNode,
NodeAnchor,
TextbookContentNode,
} from "../types";
/**
* 纯函数:将课案 nodes/edges 映射为 React Flow 格式。
* 从 node-editor.tsx 抽取,便于单元测试。
*
* v3 升级:
* - 区分教学节点type="lesson"和正文节点type="textbook_content"
* - 正文节点传入 anchors/selectedNodeId/onAddAnchor 等回调
* - 边区分 anchor/flow 类型,应用不同透明度
*/
export function toRfNodes(
nodes: LessonPlanNode[],
selectedNodeId: string | null,
): Node[] {
return nodes.map((n) => ({
id: n.id,
type: "lesson",
position: n.position,
data: { node: n } as Record<string, unknown>,
selected: n.id === selectedNodeId,
}));
export interface ToRfNodesContext {
anchors: NodeAnchor[];
selectedNodeId: string | null;
onAddRangeAnchor?: (params: {
nodeId: string;
start: number;
end: number;
textPreview: string;
}) => void;
onAddPointAnchor?: (params: {
nodeId: string;
start: number;
}) => void;
onSelectNode?: (id: string | null) => void;
onZoomChange?: (zoom: number) => void;
}
export function toRfEdges(edges: LessonPlanEdge[]): Edge[] {
return edges.map((e) => ({
...e,
animated: true,
style: { stroke: "#1976d2", strokeWidth: 2 },
}));
export function toRfNodes(
nodes: AnyLessonPlanNode[],
selectedNodeId: string | null,
ctx?: ToRfNodesContext,
): Node[] {
return nodes.map((n) => {
// 正文节点
if (n.type === "textbook_content") {
const tbNode = n as TextbookContentNode;
return {
id: tbNode.id,
type: "textbook_content",
position: tbNode.position,
data: {
node: tbNode,
anchors: ctx?.anchors ?? [],
selectedNodeId,
onAddRangeAnchor: ctx?.onAddRangeAnchor,
onAddPointAnchor: ctx?.onAddPointAnchor,
onSelectNode: ctx?.onSelectNode,
onZoomChange: ctx?.onZoomChange,
} as Record<string, unknown>,
selected: tbNode.id === selectedNodeId,
draggable: false,
};
}
// 教学节点
const lessonNode = n as LessonPlanNode;
return {
id: lessonNode.id,
type: "lesson",
position: lessonNode.position,
data: { node: lessonNode } as Record<string, unknown>,
selected: lessonNode.id === selectedNodeId,
};
});
}
export function toRfEdges(
edges: AnyLessonPlanEdge[],
selectedNodeId: string | null,
anchors: NodeAnchor[],
): Edge[] {
return edges.map((e) => {
if (e.type === "anchor") {
// 锚点边:默认 10% 透明度,选中关联节点时 100%
const anchor = anchors.find((a) => a.id === e.anchorId);
const isActive = anchor && anchor.nodeId === selectedNodeId;
return {
...e,
animated: false,
className: isActive ? "anchor-edge active" : "anchor-edge",
style: {
stroke: anchor ? getNodeColorForAnchor(anchor.nodeId) : "#9e9e9e",
strokeWidth: 2,
opacity: isActive ? 1 : 0.1,
},
};
}
// 流程边
return {
...e,
animated: true,
style: { stroke: "#1976d2", strokeWidth: 2 },
};
});
}
// 简单的颜色查找(避免循环依赖 node-summary
function getNodeColorForAnchor(_nodeId: string): string {
// 实际颜色由 CSS 类 .anchor-edge 设置,这里返回默认值
return "#1976d2";
}
/**
@@ -32,12 +115,28 @@ export function toRfEdges(edges: LessonPlanEdge[]): Edge[] {
*/
export function fromRfEdges(
rfEdges: Edge[],
): LessonPlanEdge[] {
return rfEdges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? null,
targetHandle: e.targetHandle ?? null,
}));
): AnyLessonPlanEdge[] {
return rfEdges.map((e) => {
const base = {
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? null,
targetHandle: e.targetHandle ?? null,
};
// 保留原有的 type 信息(通过 className 判断或默认为 flow
if (e.className?.includes("anchor-edge")) {
return {
...base,
type: "anchor" as const,
anchorId: e.id, // 简化:用 edge id 作为 anchorId实际应从 data 读取)
};
}
return {
...base,
type: "flow" as const,
};
});
}