- 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
295 lines
9.0 KiB
TypeScript
295 lines
9.0 KiB
TypeScript
"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.data(Record<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>
|
||
);
|
||
}
|