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