diff --git a/src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx b/src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx new file mode 100644 index 0000000..0375e6b --- /dev/null +++ b/src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import type { LessonPlanNode } from "../../types"; +import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor"; + +export interface ContextMenuState { + visible: boolean; + x: number; + y: number; + /** 右键的节点(如果是 inline-node 右键) */ + nodeId: string | null; + /** 右键的选中文本范围(如果是正文右键) */ + selectionRange: { from: number; to: number } | null; +} + +interface Props { + state: ContextMenuState; + onClose: () => void; + /** AI 协助回调(V1: 仅 toast 提示"功能开发中",V2 接入 actions-ai.ts) */ + onAiAction?: (action: "generate" | "optimize" | "differentiation" | "layered", nodeId: string) => void; +} + +/** + * V4 右键菜单:根据 state.nodeId 区分节点菜单 vs 锚定菜单。 + * + * V1 范围:展开/收起、删除、锚定 已实现。 + * V1 占位:上下移动、复制节点、AI 协助 4 项(按钮显示,点击 toast 提示)。 + * 这些占位功能在 spec §15 YAGNI 边界内,V2 接入。 + */ +export function PaperContextMenu({ state, onClose, onAiAction }: Props) { + const t = useTranslations("lessonPreparation"); + const { + toggleExpand, + expandedNodeIds, + updateNode, + removeNode, + addAnchor, + doc, + } = useLessonPlanEditor(); + + // V1 占位实现:上下移动、复制(按 spec §15 YAGNI,V2 完整实现) + const moveNodeOrder = (nodeId: string, direction: "up" | "down") => { + const teachingNodes = doc.nodes + .filter((n): n is LessonPlanNode => n.type !== "textbook_content") + .sort((a, b) => a.order - b.order); + const idx = teachingNodes.findIndex((n) => n.id === nodeId); + if (idx < 0) return; + const newIdx = direction === "up" ? idx - 1 : idx + 1; + if (newIdx < 0 || newIdx >= teachingNodes.length) return; + const a = teachingNodes[idx]; + const b = teachingNodes[newIdx]; + updateNode(a.id, { order: b.order }); + updateNode(b.id, { order: a.order }); + }; + + const copyNode = (nodeId: string) => { + void nodeId; + // V1 占位:toast 提示 + void import("sonner").then(({ toast }) => toast.info("复制节点功能将在 V2 提供")); + }; + + // AI 协助默认实现(V1 占位) + const handleAi = (action: "generate" | "optimize" | "differentiation" | "layered", nodeId: string) => { + if (onAiAction) { + onAiAction(action, nodeId); + } else { + void import("sonner").then(({ toast }) => toast.info("AI 协助功能将在 V2 提供")); + } + }; + + if (!state.visible) return null; + + const isNodeMenu = state.nodeId !== null; + const isExpanded = state.nodeId ? expandedNodeIds.includes(state.nodeId) : false; + + const handleAction = (action: () => void) => { + action(); + onClose(); + }; + + const item = (label: string, action: () => void, opts?: { danger?: boolean; ai?: boolean; shortcut?: string }) => ( + + ); + + const section = (label: string) => ( +
+ {label} +
+ ); + + const divider = () =>
; + + return ( +
e.stopPropagation()} + > + {isNodeMenu ? ( + <> + {section(t("v4.contextMenu.nodeOps"))} + {!isExpanded && item(t("v4.contextMenu.expandToPaper"), () => state.nodeId && toggleExpand(state.nodeId))} + {isExpanded && item(t("v4.contextMenu.collapseFromPaper"), () => state.nodeId && toggleExpand(state.nodeId))} + {item(t("v4.contextMenu.moveUp"), () => state.nodeId && moveNodeOrder(state.nodeId, "up"))} + {item(t("v4.contextMenu.moveDown"), () => state.nodeId && moveNodeOrder(state.nodeId, "down"))} + {divider()} + {section(t("v4.contextMenu.aiAssist"))} + {item(t("v4.contextMenu.aiGenerate"), () => state.nodeId && handleAi("generate", state.nodeId), { ai: true })} + {item(t("v4.contextMenu.aiOptimize"), () => state.nodeId && handleAi("optimize", state.nodeId), { ai: true })} + {item(t("v4.contextMenu.aiDifferentiation"), () => state.nodeId && handleAi("differentiation", state.nodeId), { ai: true })} + {item(t("v4.contextMenu.aiLayeredQuestions"), () => state.nodeId && handleAi("layered", state.nodeId), { ai: true })} + {divider()} + {item(t("v4.contextMenu.copyNode"), () => state.nodeId && copyNode(state.nodeId))} + {item(t("v4.contextMenu.deleteNode"), () => state.nodeId && removeNode(state.nodeId), { danger: true, shortcut: "⌫" })} + + ) : ( + <> + {section(t("v4.contextMenu.anchorToNode"))} + {/* 列出所有教学节点供选择锚定 */} + { + if (state.selectionRange && nodeId) { + // 用 Tiptap toggleMark 包裹选中文本 + // 实际实现在 PaperEditor 中通过 ref 调用 editor + addAnchor({ nodeId, type: "range" }); + } + onClose(); + }} + /> + + )} +
+ ); +} + +function NodeAnchorList({ onSelect }: { onSelect: (nodeId: string) => void }) { + const nodes = useLessonPlanEditor((s) => s.doc.nodes); + const teachingNodes = nodes.filter((n): n is LessonPlanNode => n.type !== "textbook_content"); + return ( +
+ {teachingNodes.map((n) => ( + + ))} +
+ ); +}