- Update actions-ai.ts and curriculum-map-view.tsx - Update detail-panel: detail-panel, detail-props, qa-editor - Update paper-editor: inline-node, inline-qa-dialog, paper-context-menu, paper-editor, textbook-tiptap-editor - Update hooks/editor-slice.ts and lib/i18n-errors.ts - Add hooks/use-node-ai-assist.ts and lib/ai-node-assist.ts - Update en/zh-CN lesson-preparation i18n messages
261 lines
7.3 KiB
TypeScript
261 lines
7.3 KiB
TypeScript
import type { StateCreator } from "zustand";
|
||
import { createId } from "@paralleldrive/cuid2";
|
||
import type {
|
||
AnchorType,
|
||
Block,
|
||
BlockData,
|
||
BlockType,
|
||
InteractionBlockData,
|
||
LessonPlanDocument,
|
||
LessonPlanNode,
|
||
NodeAnchor,
|
||
TextbookContentNode,
|
||
TextbookContentNodeData,
|
||
} from "../types";
|
||
import { defaultDataForType } from "../lib/document-migration";
|
||
import type { EditorState } from "./use-lesson-plan-editor";
|
||
|
||
/**
|
||
* V4:深拷贝 BlockData,为 interaction 节点的 turns 重新生成 id(避免 id 冲突)。
|
||
* 其他类型直接结构化克隆(JSON 序列化足够,数据全是 POJO)。
|
||
*/
|
||
function cloneBlockData(data: BlockData, type: BlockType): BlockData {
|
||
if (type === "interaction") {
|
||
const d = data as InteractionBlockData;
|
||
return {
|
||
designIntent: d.designIntent,
|
||
knowledgePointIds: [...d.knowledgePointIds],
|
||
turns: d.turns.map((t) => ({ ...t, id: createId() })),
|
||
};
|
||
}
|
||
// 其他类型:JSON 深拷贝(数据全是 POJO,无函数/Date/Symbol)
|
||
return JSON.parse(JSON.stringify(data)) as BlockData;
|
||
}
|
||
|
||
export interface EditorSlice {
|
||
planId: string;
|
||
title: string;
|
||
doc: LessonPlanDocument;
|
||
setTitle: (title: string) => void;
|
||
setPlanId: (planId: string) => void;
|
||
addNode: (type: BlockType, position?: { x: number; y: number }, title?: string) => string;
|
||
updateNode: (id: string, patch: Omit<Partial<Block>, "type">) => void;
|
||
removeNode: (id: string) => void;
|
||
/** V4:复制节点(深拷贝数据 + 新 id + 标题加"副本" + order 置末) */
|
||
duplicateNode: (id: string) => string | null;
|
||
updateTextbookContent: (data: Partial<TextbookContentNodeData>) => void;
|
||
getTextbookContentNode: () => TextbookContentNode | undefined;
|
||
/**
|
||
* V4:添加锚点(不再传 start/end,锚点位置由 Tiptap Mark 内嵌)。
|
||
*
|
||
* 兼容说明:旧调用方可能传入 { nodeId, type, start, end, textPreview },
|
||
* 这些 v3 字段会被忽略,等阶段 H 改造调用方时再清理。
|
||
*/
|
||
addAnchor: (params: {
|
||
nodeId: string;
|
||
type: AnchorType;
|
||
/** @deprecated v3 字段,v4 已由 Tiptap Mark 承载,忽略 */
|
||
start?: number;
|
||
/** @deprecated v3 字段,v4 忽略 */
|
||
end?: number;
|
||
/** @deprecated v3 字段,v4 忽略 */
|
||
textPreview?: string;
|
||
}) => string;
|
||
removeAnchor: (anchorId: string) => void;
|
||
updateAnchor: (anchorId: string, patch: Partial<NodeAnchor>) => void;
|
||
}
|
||
|
||
function reindex(nodes: LessonPlanNode[]): LessonPlanNode[] {
|
||
return nodes.map((n, i) => ({ ...n, order: i }));
|
||
}
|
||
|
||
export const createEditorSlice: StateCreator<
|
||
EditorState,
|
||
[],
|
||
[],
|
||
EditorSlice
|
||
> = (set, get) => ({
|
||
planId: "",
|
||
title: "",
|
||
doc: {
|
||
version: 4,
|
||
textbookContentNodeId: "",
|
||
nodes: [],
|
||
edges: [],
|
||
anchors: [],
|
||
expandedNodeIds: [],
|
||
},
|
||
|
||
setTitle: (title) => {
|
||
get().pushHistory();
|
||
set({ title, isDirty: true });
|
||
},
|
||
setPlanId: (planId) => set({ planId }),
|
||
|
||
addNode: (type, _position, title) => {
|
||
const id = createId();
|
||
const state = get();
|
||
state.pushHistory(); // V5-2:撤销/重做
|
||
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,
|
||
data: defaultDataForType(type),
|
||
order: nodeCount,
|
||
position: { x: 0, y: 0 }, // V4:不再使用,保留字段兼容
|
||
};
|
||
set((s) => ({
|
||
doc: { ...s.doc, nodes: [...s.doc.nodes, node] },
|
||
isDirty: true,
|
||
selectedNodeId: id,
|
||
}));
|
||
return id;
|
||
},
|
||
|
||
updateNode: (id, patch) => {
|
||
get().pushHistory(); // V5-2:撤销/重做
|
||
set((s) => ({
|
||
doc: {
|
||
...s.doc,
|
||
nodes: s.doc.nodes.map((n) =>
|
||
n.id === id
|
||
? n.type === "textbook_content"
|
||
? ({ ...n, ...patch } as TextbookContentNode)
|
||
: ({ ...n, ...patch } as LessonPlanNode)
|
||
: n,
|
||
),
|
||
},
|
||
isDirty: true,
|
||
}));
|
||
},
|
||
|
||
removeNode: (id) => {
|
||
get().pushHistory(); // V5-2:撤销/重做
|
||
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,
|
||
};
|
||
});
|
||
},
|
||
|
||
duplicateNode: (id) => {
|
||
const state = get();
|
||
const src = state.doc.nodes.find(
|
||
(n): n is LessonPlanNode => n.id === id && n.type !== "textbook_content",
|
||
);
|
||
if (!src) return null;
|
||
state.pushHistory();
|
||
const newId = createId();
|
||
const teachingNodes = state.doc.nodes.filter(
|
||
(n): n is LessonPlanNode => n.type !== "textbook_content",
|
||
);
|
||
// 深拷贝 data(避免引用共享);interaction 节点的 turns 内 id 重新生成
|
||
const clonedData = cloneBlockData(src.data, src.type);
|
||
const newNode: LessonPlanNode = {
|
||
id: newId,
|
||
type: src.type,
|
||
title: `${src.title}(副本)`,
|
||
data: clonedData,
|
||
order: teachingNodes.length,
|
||
position: { x: 0, y: 0 },
|
||
stage: src.stage,
|
||
differentiation: src.differentiation,
|
||
};
|
||
set((s) => ({
|
||
doc: { ...s.doc, nodes: [...s.doc.nodes, newNode] },
|
||
isDirty: true,
|
||
selectedNodeId: newId,
|
||
}));
|
||
return newId;
|
||
},
|
||
|
||
updateTextbookContent: (data) => {
|
||
get().pushHistory(); // V5-2:撤销/重做
|
||
set((s) => ({
|
||
doc: {
|
||
...s.doc,
|
||
nodes: s.doc.nodes.map((n) =>
|
||
n.type === "textbook_content" && n.id === s.doc.textbookContentNodeId
|
||
? { ...n, data: { ...n.data, ...data } }
|
||
: n,
|
||
),
|
||
},
|
||
isDirty: true,
|
||
}));
|
||
},
|
||
|
||
getTextbookContentNode: () => {
|
||
const state = get();
|
||
return state.doc.nodes.find(
|
||
(n): n is TextbookContentNode => n.type === "textbook_content",
|
||
);
|
||
},
|
||
|
||
addAnchor: ({ nodeId, type }) => {
|
||
const anchorId = createId();
|
||
get().pushHistory(); // V5-2:撤销/重做
|
||
set((s) => ({
|
||
doc: {
|
||
...s.doc,
|
||
anchors: [
|
||
...s.doc.anchors,
|
||
{ id: anchorId, nodeId, type },
|
||
],
|
||
},
|
||
isDirty: true,
|
||
}));
|
||
return anchorId;
|
||
},
|
||
|
||
removeAnchor: (anchorId) => {
|
||
get().pushHistory(); // V5-2:撤销/重做
|
||
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) => {
|
||
get().pushHistory(); // V5-2:撤销/重做
|
||
set((s) => ({
|
||
doc: {
|
||
...s.doc,
|
||
anchors: s.doc.anchors.map((a) =>
|
||
a.id === anchorId ? { ...a, ...patch } : a,
|
||
),
|
||
},
|
||
isDirty: true,
|
||
}));
|
||
},
|
||
});
|