Files
NextEdu/src/modules/lesson-preparation/components/node-editor.tsx
SpecialX 2197e68069 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)
2026-06-23 17:37:19 +08:00

228 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import {
ReactFlow,
Background,
Controls,
MiniMap,
type Node,
type Edge,
type NodeChange,
type EdgeChange,
type Connection,
applyEdgeChanges,
BackgroundVariant,
} from "@xyflow/react";
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 { AnyLessonPlanNode } from "../types";
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,
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, {
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, selectedNodeId, doc.anchors),
[doc.edges, selectedNodeId, doc.anchors],
);
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
changes.forEach((change) => {
if (change.type === "position" && change.position) {
// 实时拖动:每次 position 变化都更新(不再等待 dragging=false
// 但仅在节点正在被拖动或拖动结束时更新
updateNodePosition(change.id, change.position);
} else if (change.type === "remove") {
removeNode(change.id);
} else if (change.type === "select") {
selectNode(change.selected ? change.id : null);
}
});
},
[updateNodePosition, removeNode, selectNode],
);
const onConnect = useCallback(
(conn: Connection) => {
if (conn.source && conn.target) {
connect(conn.source, conn.target);
}
},
[connect],
);
// 同步 edges 变化(如拖拽重连)
const onEdgesChangeSync = useCallback(
(changes: EdgeChange[]) => {
// 简单处理:删除时调用 disconnect
const nextEdges = applyEdgeChanges(changes, rfEdges);
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, doc.edges],
);
return (
<div className="w-full h-full relative" role="application" aria-label={t("editor.canvasLabel")}>
{doc.nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div className="text-center text-on-surface-variant">
<p className="text-lg font-medium">{t("editor.canvasEmpty")}</p>
<p className="text-sm mt-1">{t("editor.canvasEmptyHint")}</p>
</div>
</div>
)}
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChangeSync}
onConnect={onConnect}
onNodeClick={(_, node) => selectNode(node.id)}
onPaneClick={() => selectNode(null)}
fitView
fitViewOptions={{ padding: 0.2, maxZoom: 1.2 }}
nodesFocusable
nodesDraggable
edgesFocusable
elementsSelectable
deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode={["Shift", "Meta", "Control"]}
defaultEdgeOptions={{
animated: true,
style: { stroke: "#1976d2", strokeWidth: 2 },
}}
proOptions={{ hideAttribution: true }}
className="bg-surface-container-low"
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="#ccc"
/>
<Controls className="!bg-surface !border-outline-variant" />
<MiniMap
className="!bg-surface !border-outline-variant"
nodeColor={(n) => {
const nodeData = (n.data as { node?: AnyLessonPlanNode }).node;
if (!nodeData) return "#9e9e9e";
return getNodeColor(nodeData.type);
}}
/>
</ReactFlow>
</div>
);
}