Files
NextEdu/src/modules/lesson-preparation/hooks/editor-slice.ts
SpecialX e962b67050 feat(lesson-preparation): update paper-editor, detail-panel, AI node assist, i18n
- 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
2026-07-04 23:02:04 +08:00

261 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}));
},
});