feat(lesson-preparation): 右键菜单(节点操作 + AI 协助 + 锚定)

This commit is contained in:
SpecialX
2026-07-04 11:29:14 +08:00
parent cbed4ef508
commit 4db77b7d1e

View File

@@ -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 YAGNIV2 完整实现)
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 }) => (
<button
type="button"
onClick={() => handleAction(action)}
className={`lp-cm-item ${opts?.danger ? "danger" : ""} ${opts?.ai ? "ai" : ""}`}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "7px 10px",
borderRadius: 4,
cursor: "pointer",
color: opts?.danger ? "#dc2626" : opts?.ai ? "var(--lp-interaction)" : "var(--foreground)",
background: "transparent",
border: "none",
width: "100%",
textAlign: "left",
fontSize: 12.5,
fontFamily: "inherit",
}}
>
<span style={{ flex: 1 }}>{label}</span>
{opts?.shortcut && (
<span style={{ fontFamily: "JetBrains Mono, monospace", fontSize: 10, color: "var(--muted-foreground)" }}>
{opts.shortcut}
</span>
)}
</button>
);
const section = (label: string) => (
<div style={{ fontSize: 9, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.08em", color: "var(--muted-foreground)", padding: "6px 10px 4px" }}>
{label}
</div>
);
const divider = () => <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }} />;
return (
<div
style={{
position: "fixed",
top: state.y,
left: state.x,
background: "var(--background)",
border: "1px solid var(--border)",
borderRadius: 6,
boxShadow: "0 6px 24px rgba(0,0,0,0.10), 0 2px 6px rgba(0,0,0,0.06)",
padding: 5,
minWidth: 220,
zIndex: 100,
}}
onClick={(e) => 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"))}
{/* 列出所有教学节点供选择锚定 */}
<NodeAnchorList
onSelect={(nodeId) => {
if (state.selectionRange && nodeId) {
// 用 Tiptap toggleMark 包裹选中文本
// 实际实现在 PaperEditor 中通过 ref 调用 editor
addAnchor({ nodeId, type: "range" });
}
onClose();
}}
/>
</>
)}
</div>
);
}
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 (
<div style={{ maxHeight: 200, overflowY: "auto" }}>
{teachingNodes.map((n) => (
<button
key={n.id}
type="button"
onClick={() => onSelect(n.id)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 10px",
width: "100%",
background: "transparent",
border: "none",
cursor: "pointer",
fontSize: 12,
color: "var(--foreground)",
textAlign: "left",
}}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: `var(--lp-dot-${n.type})` }} />
<span style={{ flex: 1 }}>{n.title}</span>
<span style={{ fontSize: 10, color: "var(--muted-foreground)" }}>{n.type}</span>
</button>
))}
</div>
);
}