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:
@@ -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);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user