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

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