feat(lesson-preparation): 中栏纸区容器(正文 + 展开节点 + 右键菜单)

This commit is contained in:
SpecialX
2026-07-04 11:29:53 +08:00
parent 4db77b7d1e
commit 6fa712ad15

View File

@@ -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<ContextMenuState>({
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 <div style={{ padding: 48, textAlign: "center", color: "var(--muted-foreground)" }}>No textbook content</div>;
}
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 (
<main
style={{
overflowY: "auto",
padding: "48px 32px 120px",
background: "radial-gradient(circle at 50% 0%, rgba(0,0,0,0.015) 0%, transparent 60%), var(--muted)",
}}
onContextMenu={(e) => {
// 默认右键 = 正文右键(锚定菜单)
if (!(e.target as HTMLElement).closest("[data-inline-node]")) {
onPaperContextMenu(e);
}
}}
>
<article
style={{
background: "var(--lp-paper)",
maxWidth: 720,
margin: "0 auto",
padding: "64px 72px",
boxShadow: "var(--lp-paper-shadow)",
borderRadius: 2,
border: "1px solid var(--lp-paper-edge)",
minHeight: 800,
fontFamily: "'Fraunces', Georgia, serif",
color: "#1a1a1a",
lineHeight: 1.7,
fontSize: 16,
}}
>
<div
style={{
fontFamily: "Inter, sans-serif",
fontSize: 11,
color: "var(--muted-foreground)",
letterSpacing: "0.08em",
textTransform: "uppercase",
marginBottom: 24,
paddingBottom: 16,
borderBottom: "1px solid var(--border)",
display: "flex",
justifyContent: "space-between",
}}
>
<span>{t("v4.paper.textbookHeader")}</span>
<span>{t("v4.paper.expandedCount", { count: expandedNodes.length })}</span>
</div>
<TextbookTiptapEditor
content={(textbookNode as { data: { content: string } }).data.content}
readonly={readonly}
/>
{/* 展开的节点:按 order 排列在正文之后(简化实现) */}
{expandedNodes.map((node) => (
<div
key={node.id}
data-inline-node={node.id}
onContextMenu={(e) => onInlineNodeContextMenu(e, node.id)}
>
<InlineNode node={node} />
</div>
))}
</article>
<PaperContextMenu
state={contextMenu}
onClose={() => setContextMenu((s) => ({ ...s, visible: false }))}
/>
</main>
);
}