diff --git a/src/modules/exams/components/exam-rich-form.tsx b/src/modules/exams/components/exam-rich-form.tsx index 33a7259..a4937f0 100644 --- a/src/modules/exams/components/exam-rich-form.tsx +++ b/src/modules/exams/components/exam-rich-form.tsx @@ -411,7 +411,7 @@ function ExamPreview({ structure }: { structure: EditorDoc }) {
{question.content.options.map((opt) => (
{opt.id}. @@ -424,7 +424,7 @@ function ExamPreview({ structure }: { structure: EditorDoc }) { {isComposite && question.content.subQuestions && question.content.subQuestions.length > 0 && (
{question.content.subQuestions.map((sub, idx) => ( -
+
({idx + 1}) diff --git a/src/modules/exams/editor/editor-to-structure.ts b/src/modules/exams/editor/editor-to-structure.ts index 002f3a3..64610ab 100644 --- a/src/modules/exams/editor/editor-to-structure.ts +++ b/src/modules/exams/editor/editor-to-structure.ts @@ -55,17 +55,18 @@ const parseOptions = ( nodes: JSONContent[] ): Array<{ id: string; text: string; isCorrect?: boolean }> => { const options: Array<{ id: string; text: string; isCorrect?: boolean }> = [] + const seenIds = new Set() for (const n of nodes) { if (n.type === "orderedList" || n.type === "bulletList") { if (Array.isArray(n.content)) { n.content.forEach((item, idx) => { const text = extractText(item).trim() const match = text.match(/^([A-Z])[.、)]\s*(.+)$/) - if (match) { - options.push({ id: match[1]!, text: match[2]! }) - } else { - options.push({ id: String.fromCharCode(65 + idx), text }) - } + const id = match ? match[1]! : String.fromCharCode(65 + idx) + // 同一题目内选项 id 去重,避免多个列表合并后出现重复 "A" + if (seenIds.has(id)) return + seenIds.add(id) + options.push({ id, text: match ? match[2]! : text }) }) } } diff --git a/src/modules/exams/editor/selection-toolbar.tsx b/src/modules/exams/editor/selection-toolbar.tsx index f48c566..18cda46 100644 --- a/src/modules/exams/editor/selection-toolbar.tsx +++ b/src/modules/exams/editor/selection-toolbar.tsx @@ -94,12 +94,36 @@ export function SelectionToolbar({ // 即使不在块内也允许显示(可标记为题目/分组) 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 }) + // 使用浏览器原生选区的可见矩形(相对视口),避免长文本选区起点 + // 在视口外时 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 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) } @@ -128,7 +152,12 @@ export function SelectionToolbar({ const chain = editor.chain().focus() 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 { // 未选中文本时:插入空题目块 chain.insertQuestion({ type, score: type === "composite" ? 5 : 2 }).run()