feat(lesson-preparation): 右键菜单(节点操作 + AI 协助 + 锚定)
This commit is contained in:
@@ -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 }) => (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user