feat(lesson-preparation): add readonly view, anchor node selector, and type guards
- Add lesson-plan-readonly-view for viewing published plans - Add anchor-node-selector and textbook-segments for canvas anchor positioning - Add i18n-errors and type-guards lib utilities - Add lesson-plan-provider-setup for provider initialization - Update actions, data-access (knowledge, versions, main), publish-service - Update blocks (blackboard, exercise, homework, import, key-point, objective, reflection) - Update editor, node-editor, node-edit-panel, pickers, and providers
This commit is contained in:
@@ -21,7 +21,7 @@ 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";
|
||||
import type { AnyLessonPlanNode, BlockType } from "../types";
|
||||
|
||||
const nodeTypes = {
|
||||
lesson: LessonNode,
|
||||
@@ -42,22 +42,27 @@ export function NodeEditor({}: Props) {
|
||||
selectNode,
|
||||
setEdges,
|
||||
addAnchor,
|
||||
updateTextbookContent,
|
||||
addNode,
|
||||
} = useLessonPlanEditor();
|
||||
|
||||
// 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 }) => {
|
||||
// 如果 nodeId 是 __selected__,使用当前选中节点
|
||||
// 如果是 __new__,提示用户先创建节点
|
||||
// __selected__ 表示使用当前选中节点
|
||||
const actualNodeId =
|
||||
params.nodeId === "__selected__"
|
||||
? selectedNodeId ?? ""
|
||||
: params.nodeId;
|
||||
if (!actualNodeId || actualNodeId === "__new__") {
|
||||
// 简化:不自动创建新节点,提示用户先选中或创建
|
||||
return;
|
||||
}
|
||||
if (!actualNodeId) return;
|
||||
addAnchor({
|
||||
nodeId: actualNodeId,
|
||||
type: "range",
|
||||
@@ -75,9 +80,7 @@ export function NodeEditor({}: Props) {
|
||||
params.nodeId === "__selected__"
|
||||
? selectedNodeId ?? ""
|
||||
: params.nodeId;
|
||||
if (!actualNodeId || actualNodeId === "__new__") {
|
||||
return;
|
||||
}
|
||||
if (!actualNodeId) return;
|
||||
addAnchor({
|
||||
nodeId: actualNodeId,
|
||||
type: "point",
|
||||
@@ -87,11 +90,25 @@ export function NodeEditor({}: Props) {
|
||||
[addAnchor, selectedNodeId],
|
||||
);
|
||||
|
||||
const handleZoomChange = useCallback(
|
||||
(zoom: number) => {
|
||||
updateTextbookContent({ zoom });
|
||||
// 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 } : {}),
|
||||
});
|
||||
},
|
||||
[updateTextbookContent],
|
||||
[addNode, addAnchor, t],
|
||||
);
|
||||
|
||||
// 使用纯函数映射 nodes/edges
|
||||
@@ -100,12 +117,13 @@ export function NodeEditor({}: Props) {
|
||||
toRfNodes(doc.nodes, selectedNodeId, {
|
||||
anchors: doc.anchors,
|
||||
selectedNodeId,
|
||||
anchorableNodes,
|
||||
onAddRangeAnchor: handleAddRangeAnchor,
|
||||
onAddPointAnchor: handleAddPointAnchor,
|
||||
onCreateNewNode: handleCreateNewNode,
|
||||
onSelectNode: selectNode,
|
||||
onZoomChange: handleZoomChange,
|
||||
}),
|
||||
[doc.nodes, doc.anchors, selectedNodeId, handleAddRangeAnchor, handleAddPointAnchor, selectNode, handleZoomChange],
|
||||
[doc.nodes, doc.anchors, selectedNodeId, anchorableNodes, handleAddRangeAnchor, handleAddPointAnchor, handleCreateNewNode, selectNode],
|
||||
);
|
||||
|
||||
const rfEdges: Edge[] = useMemo(
|
||||
@@ -173,7 +191,13 @@ export function NodeEditor({}: Props) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative" role="application" aria-label={t("editor.canvasLabel")}>
|
||||
<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">
|
||||
@@ -216,9 +240,21 @@ export function NodeEditor({}: Props) {
|
||||
<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);
|
||||
// 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>
|
||||
|
||||
Reference in New Issue
Block a user