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, "type">) => void; removeNode: (id: string) => void; /** V4:复制节点(深拷贝数据 + 新 id + 标题加"副本" + order 置末) */ duplicateNode: (id: string) => string | null; updateTextbookContent: (data: Partial) => 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) => 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, })); }, });