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

@@ -18,39 +18,108 @@ import {
import "@xyflow/react/dist/style.css";
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 { LessonPlanNode } from "../types";
import type { AnyLessonPlanNode } from "../types";
const nodeTypes = { lesson: LessonNode };
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 } =
useLessonPlanEditor();
const {
doc,
selectedNodeId,
updateNodePosition,
removeNode,
connect,
selectNode,
setEdges,
addAnchor,
updateTextbookContent,
} = useLessonPlanEditor();
// 锚点添加回调(正文节点使用)
const handleAddRangeAnchor = useCallback(
(params: { nodeId: string; start: number; end: number; textPreview: string }) => {
// 如果 nodeId 是 __selected__使用当前选中节点
// 如果是 __new__提示用户先创建节点
const actualNodeId =
params.nodeId === "__selected__"
? selectedNodeId ?? ""
: params.nodeId;
if (!actualNodeId || actualNodeId === "__new__") {
// 简化:不自动创建新节点,提示用户先选中或创建
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 || actualNodeId === "__new__") {
return;
}
addAnchor({
nodeId: actualNodeId,
type: "point",
start: params.start,
});
},
[addAnchor, selectedNodeId],
);
const handleZoomChange = useCallback(
(zoom: number) => {
updateTextbookContent({ zoom });
},
[updateTextbookContent],
);
// 使用纯函数映射 nodes/edges
const rfNodes: Node[] = useMemo(
() => toRfNodes(doc.nodes, selectedNodeId),
[doc.nodes, selectedNodeId],
() =>
toRfNodes(doc.nodes, selectedNodeId, {
anchors: doc.anchors,
selectedNodeId,
onAddRangeAnchor: handleAddRangeAnchor,
onAddPointAnchor: handleAddPointAnchor,
onSelectNode: selectNode,
onZoomChange: handleZoomChange,
}),
[doc.nodes, doc.anchors, selectedNodeId, handleAddRangeAnchor, handleAddPointAnchor, selectNode, handleZoomChange],
);
const rfEdges: Edge[] = useMemo(
() => toRfEdges(doc.edges),
[doc.edges],
() => toRfEdges(doc.edges, selectedNodeId, doc.anchors),
[doc.edges, selectedNodeId, doc.anchors],
);
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
changes.forEach((change) => {
if (change.type === "position" && change.position) {
// 拖拽结束时(dragging: false才写入最终位置,避免中间状态污染(修复 P1-1
if (change.dragging === false) {
updateNodePosition(change.id, change.position);
}
// 实时拖动:每次 position 变化都更新(不再等待 dragging=false
// 但仅在节点正在被拖动或拖动结束时更新
updateNodePosition(change.id, change.position);
} else if (change.type === "remove") {
removeNode(change.id);
} else if (change.type === "select") {
@@ -75,16 +144,32 @@ export function NodeEditor({}: Props) {
(changes: EdgeChange[]) => {
// 简单处理:删除时调用 disconnect
const nextEdges = applyEdgeChanges(changes, rfEdges);
const ourEdges = nextEdges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? null,
targetHandle: e.targetHandle ?? null,
}));
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],
[rfEdges, setEdges, doc.edges],
);
return (
@@ -131,7 +216,7 @@ export function NodeEditor({}: Props) {
<MiniMap
className="!bg-surface !border-outline-variant"
nodeColor={(n) => {
const nodeData = (n.data as { node?: LessonPlanNode }).node;
const nodeData = (n.data as { node?: AnyLessonPlanNode }).node;
if (!nodeData) return "#9e9e9e";
return getNodeColor(nodeData.type);
}}