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)
This commit is contained in:
SpecialX
2026-06-23 17:37:19 +08:00
parent 1fcef5c3aa
commit 2197e68069
34 changed files with 3190 additions and 402 deletions

View File

@@ -3,12 +3,19 @@
import { create } from "zustand";
import { createId } from "@paralleldrive/cuid2";
import type {
AnchorEdge,
AnchorType,
AnyLessonPlanEdge,
Block,
BlockType,
FlowEdge,
LessonPlanDocument,
LessonPlanEdge,
LessonPlanNode,
NodeAnchor,
TextbookContentNode,
TextbookContentNodeData,
} from "../types";
import { defaultDataForType } from "../lib/document-migration";
interface EditorState {
planId: string;
@@ -28,9 +35,25 @@ interface EditorState {
updateNodePosition: (id: string, position: { x: number; y: number }) => void;
removeNode: (id: string) => void;
// 正文节点操作
updateTextbookContent: (data: Partial<TextbookContentNodeData>) => void;
getTextbookContentNode: () => TextbookContentNode | undefined;
// 锚点操作
addAnchor: (params: {
nodeId: string;
type: AnchorType;
start: number;
end?: number;
textPreview?: string;
}) => string;
removeAnchor: (anchorId: string) => void;
updateAnchor: (anchorId: string, patch: Partial<NodeAnchor>) => void;
// 连线
connect: (source: string, target: string) => void;
disconnect: (edgeId: string) => void;
setEdges: (edges: LessonPlanEdge[]) => void;
setEdges: (edges: AnyLessonPlanEdge[]) => void;
selectNode: (id: string | null) => void;
@@ -43,18 +66,16 @@ function reindex(nodes: LessonPlanNode[]): LessonPlanNode[] {
return nodes.map((n, i) => ({ ...n, order: i }));
}
function defaultData(type: BlockType): Block["data"] {
return type === "exercise"
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
: type === "text_study"
? { sourceText: "", annotations: [], knowledgePointIds: [] }
: { html: "", knowledgePointIds: [] };
}
export const useLessonPlanEditor = create<EditorState>((set, get) => ({
planId: "",
title: "",
doc: { version: 2, nodes: [], edges: [] },
doc: {
version: 3,
textbookContentNodeId: "",
nodes: [],
edges: [],
anchors: [],
},
isDirty: false,
isSaving: false,
lastSavedAt: null,
@@ -77,12 +98,17 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
addNode: (type, position, title) => {
const id = createId();
const nodeCount = get().doc.nodes.length;
const state = get();
// 教学节点 order 从 0 开始(正文节点 order=-1 不计入)
const teachingNodes = state.doc.nodes.filter(
(n): n is LessonPlanNode => n.type !== "textbook_content",
);
const nodeCount = teachingNodes.length;
const node: LessonPlanNode = {
id,
type,
title: title ?? type, // 调用方应传入翻译后的标题fallback 为 type 键
data: defaultData(type),
title: title ?? type,
data: defaultDataForType(type),
order: nodeCount,
position: position ?? {
x: 80 + (nodeCount % 4) * 280,
@@ -102,37 +128,139 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
doc: {
...s.doc,
nodes: s.doc.nodes.map((n) =>
n.id === id ? { ...n, ...patch } : n,
n.id === id
? n.type === "textbook_content"
? { ...n, ...patch } as TextbookContentNode
: { ...n, ...patch } as LessonPlanNode
: n,
),
},
isDirty: true,
})),
// 实时拖动:每次调用立即更新位置(不再等待 dragging=false
updateNodePosition: (id, position) =>
set((s) => ({
doc: {
...s.doc,
nodes: s.doc.nodes.map((n) =>
n.id === id ? { ...n, position } : n,
n.id === id
? n.type === "textbook_content"
? { ...n, position } as TextbookContentNode
: { ...n, position } as LessonPlanNode
: n,
),
},
isDirty: true,
})),
removeNode: (id) =>
set((s) => {
const remainingTeachingNodes = reindex(
s.doc.nodes.filter(
(n): n is LessonPlanNode => n.id !== id && n.type !== "textbook_content",
),
);
const textbookNode = s.doc.nodes.find(
(n): n is TextbookContentNode => n.type === "textbook_content",
);
const nodes = textbookNode ? [textbookNode, ...remainingTeachingNodes] : remainingTeachingNodes;
return {
doc: {
...s.doc,
nodes,
edges: s.doc.edges.filter(
(e) => e.source !== id && e.target !== id,
),
// 同时移除关联的锚点
anchors: s.doc.anchors.filter((a) => a.nodeId !== id),
},
isDirty: true,
selectedNodeId:
s.selectedNodeId === id ? null : s.selectedNodeId,
};
}),
// ---- 正文节点操作 ----
updateTextbookContent: (data) =>
set((s) => ({
doc: {
...s.doc,
nodes: reindex(s.doc.nodes.filter((n) => n.id !== id)),
edges: s.doc.edges.filter(
(e) => e.source !== id && e.target !== id,
nodes: s.doc.nodes.map((n) =>
n.type === "textbook_content" && n.id === s.doc.textbookContentNodeId
? { ...n, data: { ...n.data, ...data } }
: n,
),
},
isDirty: true,
selectedNodeId:
s.selectedNodeId === id ? null : s.selectedNodeId,
})),
getTextbookContentNode: () => {
const state = get();
return state.doc.nodes.find(
(n): n is TextbookContentNode => n.type === "textbook_content",
);
},
// ---- 锚点操作 ----
addAnchor: ({ nodeId, type, start, end, textPreview }) => {
const anchorId = createId();
const state = get();
const textbookNodeId = state.doc.textbookContentNodeId;
const anchor: NodeAnchor = {
id: anchorId,
nodeId,
type,
start,
...(end !== undefined ? { end } : {}),
...(textPreview ? { textPreview } : {}),
};
const edge: AnchorEdge = {
id: `ae_${nodeId}_${textbookNodeId}_${anchorId.slice(0, 6)}`,
source: nodeId,
target: textbookNodeId,
type: "anchor",
anchorId,
};
set((s) => ({
doc: {
...s.doc,
anchors: [...s.doc.anchors, anchor],
edges: [...s.doc.edges, edge],
},
isDirty: true,
}));
return anchorId;
},
removeAnchor: (anchorId) =>
set((s) => ({
doc: {
...s.doc,
anchors: s.doc.anchors.filter((a) => a.id !== anchorId),
edges: s.doc.edges.filter(
(e) => !(e.type === "anchor" && e.anchorId === anchorId),
),
},
isDirty: true,
})),
updateAnchor: (anchorId, patch) =>
set((s) => ({
doc: {
...s.doc,
anchors: s.doc.anchors.map((a) =>
a.id === anchorId ? { ...a, ...patch } : a,
),
},
isDirty: true,
})),
// ---- 连线 ----
connect: (source, target) =>
set((s) => {
// 避免重复连线
@@ -142,10 +270,11 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
)
)
return s;
const edge: LessonPlanEdge = {
const edge: FlowEdge = {
id: `e_${source}_${target}_${createId().slice(0, 6)}`,
source,
target,
type: "flow",
};
return { doc: { ...s.doc, edges: [...s.doc.edges, edge] }, isDirty: true };
}),