feat(lesson-preparation): 中栏纸区容器(正文 + 展开节点 + 右键菜单)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user