diff --git a/src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx b/src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx new file mode 100644 index 0000000..255dcc3 --- /dev/null +++ b/src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor"; +import { TextbookTiptapEditor } from "./textbook-tiptap-editor"; +import { InlineNode } from "./inline-node"; +import { PaperContextMenu, type ContextMenuState } from "./paper-context-menu"; +import type { LessonPlanNode } from "../../types"; + +/** + * V4 中栏纸区: + * - 教材正文 Tiptap 编辑器(max-w-720px 白纸) + * - 展开的节点按 order 顺序在段落间插入 inline-node + * - 右键菜单(节点操作 / 锚定) + * + * 注意:V4 的 inline-node 插入位置由节点 order 决定,不由锚点位置决定。 + * 锚点是正文内的视觉标记,inline-node 是独立块。 + * 当前实现:所有 inline-node 渲染在正文之后(简化)。 + * 完整实现需要把正文按段落分割,在段落间插入 inline-node。 + */ +export function PaperEditor({ readonly }: { readonly?: boolean }) { + const t = useTranslations("lessonPreparation"); + const doc = useLessonPlanEditor((s) => s.doc); + const expandedNodeIds = useLessonPlanEditor((s) => s.expandedNodeIds); + const [contextMenu, setContextMenu] = useState({ + visible: false, + x: 0, + y: 0, + nodeId: null, + selectionRange: null, + }); + + const textbookNode = doc.nodes.find((n) => n.type === "textbook_content"); + const teachingNodes = useMemo( + () => + doc.nodes + .filter((n): n is LessonPlanNode => n.type !== "textbook_content") + .sort((a, b) => a.order - b.order), + [doc.nodes], + ); + + const expandedNodes = useMemo( + () => teachingNodes.filter((n) => expandedNodeIds.includes(n.id)), + [teachingNodes, expandedNodeIds], + ); + + if (!textbookNode) { + return
No textbook content
; + } + + const onPaperContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + const sel = window.getSelection(); + const hasSelection = sel && sel.toString().length > 0; + setContextMenu({ + visible: true, + x: e.clientX, + y: e.clientY, + nodeId: null, + selectionRange: hasSelection ? { from: 0, to: 0 } : null, // 实际偏移由 editor 给 + }); + }; + + const onInlineNodeContextMenu = (e: React.MouseEvent, nodeId: string) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ + visible: true, + x: e.clientX, + y: e.clientY, + nodeId, + selectionRange: null, + }); + }; + + return ( +
{ + // 默认右键 = 正文右键(锚定菜单) + if (!(e.target as HTMLElement).closest("[data-inline-node]")) { + onPaperContextMenu(e); + } + }} + > +
+
+ {t("v4.paper.textbookHeader")} + {t("v4.paper.expandedCount", { count: expandedNodes.length })} +
+ + + + {/* 展开的节点:按 order 排列在正文之后(简化实现) */} + {expandedNodes.map((node) => ( +
onInlineNodeContextMenu(e, node.id)} + > + +
+ ))} +
+ + setContextMenu((s) => ({ ...s, visible: false }))} + /> +
+ ); +}