fix(exams): fix duplicate React keys and composite question marking

- Fix parseOptions: deduplicate option ids within a single question to
  avoid multiple "A" keys when a question contains multiple lists
- Fix ExamPreview: use composite keys (questionId-optId, questionId-sub-id)
  to ensure global uniqueness across questions
- Fix selection-toolbar: when wrapInQuestion fails inside isolating nodes
  (e.g. composite question block), fall back to insertQuestion instead of
  silently doing nothing
This commit is contained in:
SpecialX
2026-06-24 14:19:46 +08:00
parent 1f28efbeb6
commit df9561128b
3 changed files with 44 additions and 14 deletions

View File

@@ -411,7 +411,7 @@ function ExamPreview({ structure }: { structure: EditorDoc }) {
<div className="space-y-1.5 pl-8 mb-2"> <div className="space-y-1.5 pl-8 mb-2">
{question.content.options.map((opt) => ( {question.content.options.map((opt) => (
<div <div
key={opt.id} key={`${question.id}-${opt.id}`}
className="text-sm text-foreground/80 flex gap-2" className="text-sm text-foreground/80 flex gap-2"
> >
<span className="font-medium min-w-[20px]">{opt.id}.</span> <span className="font-medium min-w-[20px]">{opt.id}.</span>
@@ -424,7 +424,7 @@ function ExamPreview({ structure }: { structure: EditorDoc }) {
{isComposite && question.content.subQuestions && question.content.subQuestions.length > 0 && ( {isComposite && question.content.subQuestions && question.content.subQuestions.length > 0 && (
<div className="mt-3 ml-4 space-y-3 border-l-2 border-primary/30 pl-4"> <div className="mt-3 ml-4 space-y-3 border-l-2 border-primary/30 pl-4">
{question.content.subQuestions.map((sub, idx) => ( {question.content.subQuestions.map((sub, idx) => (
<div key={sub.id} className="text-sm"> <div key={`${question.id}-sub-${sub.id}`} className="text-sm">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="font-medium text-foreground/80"> <span className="font-medium text-foreground/80">
({idx + 1}) ({idx + 1})

View File

@@ -55,17 +55,18 @@ const parseOptions = (
nodes: JSONContent[] nodes: JSONContent[]
): Array<{ id: string; text: string; isCorrect?: boolean }> => { ): Array<{ id: string; text: string; isCorrect?: boolean }> => {
const options: Array<{ id: string; text: string; isCorrect?: boolean }> = [] const options: Array<{ id: string; text: string; isCorrect?: boolean }> = []
const seenIds = new Set<string>()
for (const n of nodes) { for (const n of nodes) {
if (n.type === "orderedList" || n.type === "bulletList") { if (n.type === "orderedList" || n.type === "bulletList") {
if (Array.isArray(n.content)) { if (Array.isArray(n.content)) {
n.content.forEach((item, idx) => { n.content.forEach((item, idx) => {
const text = extractText(item).trim() const text = extractText(item).trim()
const match = text.match(/^([A-Z])[.、)]\s*(.+)$/) const match = text.match(/^([A-Z])[.、)]\s*(.+)$/)
if (match) { const id = match ? match[1]! : String.fromCharCode(65 + idx)
options.push({ id: match[1]!, text: match[2]! }) // 同一题目内选项 id 去重,避免多个列表合并后出现重复 "A"
} else { if (seenIds.has(id)) return
options.push({ id: String.fromCharCode(65 + idx), text }) seenIds.add(id)
} options.push({ id, text: match ? match[2]! : text })
}) })
} }
} }

View File

@@ -94,12 +94,36 @@ export function SelectionToolbar({
// 即使不在块内也允许显示(可标记为题目/分组) // 即使不在块内也允许显示(可标记为题目/分组)
void inBlock void inBlock
// 使用浏览器原生选区的可见矩形(相对视口),避免长文本选区起点
// 在视口外时 coordsAtPos 返回负坐标导致工具栏跑到页面顶部
const domSelection = window.getSelection()
let top: number
let left: number
if (domSelection && domSelection.rangeCount > 0) {
const rect = domSelection.getRangeAt(0).getBoundingClientRect()
// 选区不可见(滚动出视口)时 rect 为空矩形,此时不显示工具栏
if (rect.width === 0 && rect.height === 0) {
setCoords(null)
setHasSelection(false)
return
}
top = rect.top - 44 // 浮在选区上方
left = rect.left + rect.width / 2
} else {
// 降级:使用 ProseMirror 的 coordsAtPos
const view = editor.view const view = editor.view
const start = view.coordsAtPos(from) const start = view.coordsAtPos(from)
const end = view.coordsAtPos(to) const end = view.coordsAtPos(to)
const top = Math.min(start.top, end.top) - 44 // 浮在选区上方 top = Math.min(start.top, end.top) - 44
const left = (start.left + end.right) / 2 left = (start.left + end.right) / 2
setCoords({ top, left }) }
// 限制在视口内,避免超出顶部或左右边界
const clampedTop = Math.max(8, Math.min(top, window.innerHeight - 60))
const clampedLeft = Math.max(120, Math.min(left, window.innerWidth - 120))
setCoords({ top: clampedTop, left: clampedLeft })
setHasSelection(true) setHasSelection(true)
} }
@@ -128,7 +152,12 @@ export function SelectionToolbar({
const chain = editor.chain().focus() const chain = editor.chain().focus()
if (hasTextSelection) { if (hasTextSelection) {
// 选中文本时:包裹为题目块 // 选中文本时:包裹为题目块
chain.wrapInQuestion({ type, score: type === "composite" ? 5 : 2 }).run() // 注意:在 isolating 节点(如复合题)内,wrapIn 可能失败,
// 此时降级为插入空题目块
const success = chain.wrapInQuestion({ type, score: type === "composite" ? 5 : 2 }).run()
if (!success) {
chain.insertQuestion({ type, score: type === "composite" ? 5 : 2 }).run()
}
} else { } else {
// 未选中文本时:插入空题目块 // 未选中文本时:插入空题目块
chain.insertQuestion({ type, score: type === "composite" ? 5 : 2 }).run() chain.insertQuestion({ type, score: type === "composite" ? 5 : 2 }).run()