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:
@@ -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})
|
||||||
|
|||||||
@@ -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 })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,12 +94,36 @@ export function SelectionToolbar({
|
|||||||
// 即使不在块内也允许显示(可标记为题目/分组)
|
// 即使不在块内也允许显示(可标记为题目/分组)
|
||||||
void inBlock
|
void inBlock
|
||||||
|
|
||||||
const view = editor.view
|
// 使用浏览器原生选区的可见矩形(相对视口),避免长文本选区起点
|
||||||
const start = view.coordsAtPos(from)
|
// 在视口外时 coordsAtPos 返回负坐标导致工具栏跑到页面顶部
|
||||||
const end = view.coordsAtPos(to)
|
const domSelection = window.getSelection()
|
||||||
const top = Math.min(start.top, end.top) - 44 // 浮在选区上方
|
let top: number
|
||||||
const left = (start.left + end.right) / 2
|
let left: number
|
||||||
setCoords({ top, left })
|
|
||||||
|
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 start = view.coordsAtPos(from)
|
||||||
|
const end = view.coordsAtPos(to)
|
||||||
|
top = Math.min(start.top, end.top) - 44
|
||||||
|
left = (start.left + end.right) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制在视口内,避免超出顶部或左右边界
|
||||||
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user