- 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)
228 lines
6.8 KiB
TypeScript
228 lines
6.8 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,
|
||
} 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>
|
||
);
|
||
}
|