Files
NextEdu/src/modules/exams/editor/selection-toolbar.tsx
SpecialX 1f28efbeb6 feat(exams): add section/group structure nodes with auto stats
Distinguish structural levels from questions:
- sectionBlock (new): top-level volume like "第Ⅰ卷 选择题(共24分)"
- groupBlock (enhanced): section like "一、选择题" with instruction field
- questionBlock (composite): reserved for reading comprehension

Key changes:
- New section-block.tsx extension with level attr (1=卷, 2=部分) and
  auto-computed question count + total score in NodeView
- group-block.tsx: add instruction field ("每小题3分"), auto stats display
- editor-to-structure.ts: recursive buildStructureNode supports arbitrary
  nesting (section > group > question), computeStats accumulates scores
- exam-rich-form.tsx ExamPreview: render section/group/question with
  distinct styles and stats badges
- selection-toolbar.tsx: add "分卷" button (Layers icon)
- exam-rich-editor.tsx: register SectionBlock, expose insertSection/
  wrapInSection via ref
- actions.ts: AI prompt now outputs volumes[] + groups[] structure with
  instruction; buildTiptapDocFromAiResponse generates nested sectionBlock
- i18n: add markSection keys (zh-CN/en)

Structural nodes are NOT questions: their question count and total score
are automatically computed from child questions, not manually set.
2026-06-24 14:07:29 +08:00

229 lines
6.4 KiB
TypeScript

"use client"
import { useEffect, useState } from "react"
import type { Editor } from "@tiptap/react"
import {
Box,
Brackets,
CircleSlash,
FileText,
Heading,
Layers,
Underline,
} from "lucide-react"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import type { QuestionBlockType } from "./extensions/question-block"
interface SelectionToolbarProps {
editor: Editor | null
/** 当前选区所在题目块类型(用于显示状态) */
activeQuestionType?: QuestionBlockType
className?: string
}
interface ToolbarButtonProps {
onClick: () => void
icon: React.ElementType
label: string
active?: boolean
}
function ToolbarButton({ onClick, icon: Icon, label, active }: ToolbarButtonProps) {
return (
<Button
type="button"
variant={active ? "secondary" : "ghost"}
size="sm"
className={cn(
"h-7 gap-1 px-2 text-xs",
active && "bg-primary/15 text-primary hover:bg-primary/20"
)}
onMouseDown={(e) => {
// 防止点击按钮时编辑器失焦
e.preventDefault()
}}
onClick={(e) => {
e.preventDefault()
onClick()
}}
title={label}
>
<Icon className="h-3.5 w-3.5" />
<span>{label}</span>
</Button>
)
}
/**
* 选区标记浮层 —— 选中文本时浮现,提供"标记为题目/分组/加点字/填空"等快捷操作。
* 跟随选区位置定位,使用 getBoundingClientRect 计算坐标。
*/
export function SelectionToolbar({
editor,
activeQuestionType,
className,
}: SelectionToolbarProps) {
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
const [hasSelection, setHasSelection] = useState(false)
useEffect(() => {
if (!editor) return
const updatePosition = () => {
const { from, to, empty } = editor.state.selection
if (empty || from === to) {
setCoords(null)
setHasSelection(false)
return
}
// 检查选区是否在 questionBlock/groupBlock 内部
const $from = editor.state.selection.$from
const depth = $from.depth
let inBlock = false
for (let d = depth; d > 0; d--) {
const node = $from.node(d)
if (node.type.name === "questionBlock" || node.type.name === "groupBlock") {
inBlock = true
break
}
}
// 即使不在块内也允许显示(可标记为题目/分组)
void inBlock
const view = editor.view
const start = view.coordsAtPos(from)
const end = view.coordsAtPos(to)
const top = Math.min(start.top, end.top) - 44 // 浮在选区上方
const left = (start.left + end.right) / 2
setCoords({ top, left })
setHasSelection(true)
}
editor.on("selectionUpdate", updatePosition)
editor.on("blur", () => {
// 延迟以允许点击工具栏按钮
setTimeout(() => {
if (!editor.isFocused) {
setCoords(null)
setHasSelection(false)
}
}, 200)
})
return () => {
editor.off("selectionUpdate", updatePosition)
}
}, [editor])
if (!editor || !hasSelection || !coords) return null
const { from, to, empty } = editor.state.selection
const hasTextSelection = !empty && from !== to
const insertQuestion = (type: QuestionBlockType) => {
const chain = editor.chain().focus()
if (hasTextSelection) {
// 选中文本时:包裹为题目块
chain.wrapInQuestion({ type, score: type === "composite" ? 5 : 2 }).run()
} else {
// 未选中文本时:插入空题目块
chain.insertQuestion({ type, score: type === "composite" ? 5 : 2 }).run()
}
}
const insertGroup = () => {
const chain = editor.chain().focus()
if (hasTextSelection) {
chain.wrapInGroup("一、选择题", "").run()
} else {
chain.insertGroup("一、选择题", "").run()
}
}
const insertSection = () => {
const chain = editor.chain().focus()
if (hasTextSelection) {
chain.wrapInSection("第Ⅰ卷 选择题", 1).run()
} else {
chain.insertSection("第Ⅰ卷 选择题", 1).run()
}
}
const toggleDotted = () => {
editor.chain().focus().toggleDotted().run()
}
const insertBlank = () => {
editor.chain().focus().insertBlank().run()
}
const insertImage = () => {
// 触发隐藏的文件输入
const input = document.createElement("input")
input.type = "file"
input.accept = "image/*"
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
const formData = new FormData()
formData.append("file", file)
formData.append("targetType", "exam")
try {
const res = await fetch("/api/upload", { method: "POST", body: formData })
const data = await res.json()
if (data?.success && data?.url) {
editor.chain().focus().setImage({ src: data.url, fileId: data.id }).run()
}
} catch (e) {
console.error("[SelectionToolbar] upload failed", e)
}
}
input.click()
}
return (
<div
role="toolbar"
aria-label="选区标记工具栏"
className={cn(
"fixed z-50 flex items-center gap-0.5 rounded-md border bg-popover/95 p-1 shadow-md backdrop-blur",
className
)}
style={{
top: `${Math.max(8, coords.top)}px`,
left: `${coords.left}px`,
transform: "translateX(-50%)",
}}
>
<ToolbarButton onClick={insertSection} icon={Layers} label="分卷" />
<ToolbarButton onClick={insertGroup} icon={Heading} label="大题" />
<ToolbarButton
onClick={() => insertQuestion("single_choice")}
icon={CircleSlash}
label="单选"
active={activeQuestionType === "single_choice"}
/>
<ToolbarButton
onClick={() => insertQuestion("text")}
icon={FileText}
label="填空/简答"
active={activeQuestionType === "text"}
/>
<ToolbarButton
onClick={() => insertQuestion("composite")}
icon={Brackets}
label="复合"
active={activeQuestionType === "composite"}
/>
<div className="mx-1 h-5 w-px bg-border" />
<ToolbarButton onClick={toggleDotted} icon={Underline} label="加点字" />
<ToolbarButton onClick={insertBlank} icon={Box} label="填空" />
<ToolbarButton onClick={insertImage} icon={FileText} label="图片" />
</div>
)
}