Files
NextEdu/src/modules/lesson-preparation/components/node-editor.tsx
SpecialX 25dca843be feat(lesson-preparation): major update with AI features, schedules, and new components
- Add actions-schedules.ts and data-access-schedules.ts for schedule management

- Add AI differentiation, AI feedback, consistency check dialogs

- Add attachment-picker, curriculum-heatmap, print-view, version-diff-view

- Add lesson-plan-mobile-view and schedule-dialog components

- Add lib: ai-differentiation, ai-feedback, auto-layout, consistency-check,

  curriculum-coverage, export, version-diff

- Add history-slice hook for version history

- Update existing components, hooks, providers, services, types

- Add teacher lesson-plans heatmap and library pages
2026-07-04 10:22:10 +08:00

295 lines
9.0 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,
Panel,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { LayoutGrid } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
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, BlockType } 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,
addNode,
autoLayout,
} = useLessonPlanEditor();
// V5-8自动布局按钮
const handleAutoLayout = useCallback(() => {
autoLayout("TB");
}, [autoLayout]);
// P1-1构建可锚定的教学节点列表排除正文节点
const anchorableNodes = useMemo(
() =>
doc.nodes
.filter((n): n is Extract<AnyLessonPlanNode, { type: BlockType }> => n.type !== "textbook_content")
.map((n) => ({ id: n.id, title: n.title, type: n.type })),
[doc.nodes],
);
// 锚点添加回调(正文节点使用)
const handleAddRangeAnchor = useCallback(
(params: { nodeId: string; start: number; end: number; textPreview: string }) => {
// __selected__ 表示使用当前选中节点
const actualNodeId =
params.nodeId === "__selected__"
? selectedNodeId ?? ""
: params.nodeId;
if (!actualNodeId) 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) return;
addAnchor({
nodeId: actualNodeId,
type: "point",
start: params.start,
});
},
[addAnchor, selectedNodeId],
);
// P1-1创建新节点并锚定
const handleCreateNewNode = useCallback(
(params: {
anchorType: "range" | "point";
start: number;
end?: number;
textPreview?: string;
}) => {
// 默认创建 rich_text 节点(最通用的类型),用户可后续切换
const newNodeId = addNode("rich_text", undefined, t("blockType.rich_text"));
addAnchor({
nodeId: newNodeId,
type: params.anchorType,
start: params.start,
...(params.end !== undefined ? { end: params.end } : {}),
...(params.textPreview ? { textPreview: params.textPreview } : {}),
});
},
[addNode, addAnchor, t],
);
// 使用纯函数映射 nodes/edges
const rfNodes: Node[] = useMemo(
() =>
toRfNodes(doc.nodes, selectedNodeId, {
anchors: doc.anchors,
selectedNodeId,
anchorableNodes,
onAddRangeAnchor: handleAddRangeAnchor,
onAddPointAnchor: handleAddPointAnchor,
onCreateNewNode: handleCreateNewNode,
onSelectNode: selectNode,
}),
[doc.nodes, doc.anchors, selectedNodeId, anchorableNodes, handleAddRangeAnchor, handleAddPointAnchor, handleCreateNewNode, selectNode],
);
const rfEdges: Edge[] = useMemo(
() => toRfEdges(doc.edges, selectedNodeId, doc.anchors, doc.nodes),
[doc.edges, selectedNodeId, doc.anchors, doc.nodes],
);
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")}
// 禁用整个画布的浏览器默认右键菜单
onContextMenu={(e) => e.preventDefault()}
>
{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
panOnDrag
zoomOnPinch
zoomOnDoubleClick={false}
selectionOnDrag={false}
deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode={["Shift", "Meta", "Control"]}
defaultEdgeOptions={{
animated: true,
style: { stroke: "#1976d2", strokeWidth: 2 },
}}
proOptions={{ hideAttribution: true }}
className="bg-surface-container-low"
onlyRenderVisibleElements
minZoom={0.2}
maxZoom={2.5}
elevateNodesOnSelect={false}
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="#ccc"
/>
<Controls className="!bg-surface !border-outline-variant" />
{/* V5-8自动布局按钮 */}
<Panel position="top-right" className="!m-2">
<Button
variant="outline"
size="sm"
onClick={handleAutoLayout}
disabled={doc.nodes.length === 0}
title={t("editor.autoLayoutHint")}
aria-label={t("editor.autoLayout")}
>
<LayoutGrid className="w-4 h-4 mr-1" />
{t("editor.autoLayout")}
</Button>
</Panel>
<MiniMap
className="!bg-surface !border-outline-variant"
nodeColor={(n) => {
// V3 修复:从 React Flow 的 Node.dataRecord<string, unknown>)安全提取 node 字段
// 使用类型守卫替代 as 断言
const data = n.data;
if (!data || typeof data !== "object") return "#9e9e9e";
const nodeData = data.node;
if (
nodeData &&
typeof nodeData === "object" &&
nodeData !== null &&
"type" in nodeData &&
typeof nodeData.type === "string"
) {
return getNodeColor(nodeData.type);
}
return "#9e9e9e";
}}
/>
</ReactFlow>
</div>
);
}