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:
304
src/modules/lesson-preparation/lib/anchor-injector.ts
Normal file
304
src/modules/lesson-preparation/lib/anchor-injector.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 模板初始化:根据骨架生成初始 content(v2)----
|
||||
// ---- 模板初始化:根据骨架生成初始 content(v3,无正文节点)----
|
||||
// 注意:此函数不创建正文节点,调用方应使用 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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 的 TranslationValues(string | 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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user