revert: roll back to ccf6c03 state (composite sub-questions preserved)
Reverts the following commits that broke composite question handling: -85661a5auto-detect composite sub-questions from text patterns -064b3cfuse slice to preserve full content when wrapping selections -2562de7remove isolating to allow nested question blocks Restores the working state fromccf6c03where: - wrapInQuestion fails gracefully in isolating nodes - selected text is preserved when creating sub-questions - composite question structure is intact
This commit is contained in:
@@ -180,6 +180,24 @@ export function ExamRichForm() {
|
|||||||
? editorDocToStructure(editorDoc, values.title)
|
? editorDocToStructure(editorDoc, values.title)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
// 调试:查看复合题的子题解析结果
|
||||||
|
if (previewStructure && typeof window !== "undefined") {
|
||||||
|
const composites = previewStructure.questions.filter((q) => q.type === "composite")
|
||||||
|
if (composites.length > 0) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("[ExamPreview] composites:", composites.map((q) => ({
|
||||||
|
id: q.id,
|
||||||
|
textLength: q.content.text.length,
|
||||||
|
textPreview: q.content.text.slice(0, 80),
|
||||||
|
subQuestionCount: q.content.subQuestions?.length ?? 0,
|
||||||
|
subQuestions: q.content.subQuestions?.map((s) => ({
|
||||||
|
text: s.text.slice(0, 50),
|
||||||
|
score: s.score,
|
||||||
|
})),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* 顶部工具栏:基本信息(单行) + AI 自动标记 + 保存 */}
|
{/* 顶部工具栏:基本信息(单行) + AI 自动标记 + 保存 */}
|
||||||
|
|||||||
@@ -11,19 +11,7 @@ import type {
|
|||||||
const extractText = (node: JSONContent | undefined): string => {
|
const extractText = (node: JSONContent | undefined): string => {
|
||||||
if (!node) return ""
|
if (!node) return ""
|
||||||
if (node.type === "text") return node.text ?? ""
|
if (node.type === "text") return node.text ?? ""
|
||||||
if (Array.isArray(node.content)) {
|
if (Array.isArray(node.content)) return node.content.map(extractText).join("")
|
||||||
// 段落之间插入换行符,避免不同段落文本被直接连接
|
|
||||||
return node.content
|
|
||||||
.map((child) => {
|
|
||||||
const text = extractText(child)
|
|
||||||
// 段落/列表项后加换行,保持文本结构
|
|
||||||
if (child.type === "paragraph" || child.type === "listItem") {
|
|
||||||
return text + "\n"
|
|
||||||
}
|
|
||||||
return text
|
|
||||||
})
|
|
||||||
.join("")
|
|
||||||
}
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,14 +106,15 @@ const buildQuestion = (qb: JSONContent): EditorQuestion => {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
// 提取题干文本(过滤掉列表和图片,它们单独处理)
|
const text = extractText({
|
||||||
const textNodes = nonQuestionBlocks.filter(
|
type: "doc",
|
||||||
|
content: nonQuestionBlocks.filter(
|
||||||
(n) =>
|
(n) =>
|
||||||
n.type !== "orderedList" &&
|
n.type !== "orderedList" &&
|
||||||
n.type !== "bulletList" &&
|
n.type !== "bulletList" &&
|
||||||
n.type !== "image"
|
n.type !== "image"
|
||||||
)
|
),
|
||||||
const text = extractText({ type: "doc", content: textNodes })
|
})
|
||||||
const options = parseOptions(nonQuestionBlocks)
|
const options = parseOptions(nonQuestionBlocks)
|
||||||
const blanks = collectBlanks(nonQuestionBlocks)
|
const blanks = collectBlanks(nonQuestionBlocks)
|
||||||
const images = collectImages(nonQuestionBlocks)
|
const images = collectImages(nonQuestionBlocks)
|
||||||
@@ -134,112 +123,9 @@ const buildQuestion = (qb: JSONContent): EditorQuestion => {
|
|||||||
if (blanks.length > 0) content.blanks = blanks
|
if (blanks.length > 0) content.blanks = blanks
|
||||||
if (images.length > 0) content.images = images
|
if (images.length > 0) content.images = images
|
||||||
if (subQuestions.length > 0) content.subQuestions = subQuestions
|
if (subQuestions.length > 0) content.subQuestions = subQuestions
|
||||||
|
|
||||||
// 复合题:如果没有显式子题,尝试从文本模式识别子题
|
|
||||||
// (如 "1.xxx", "2.xxx", "(1)xxx", "①xxx" 等)
|
|
||||||
if (type === "composite" && subQuestions.length === 0 && content.text) {
|
|
||||||
const detected = detectSubQuestionsFromText(content.text)
|
|
||||||
if (detected.length > 0) {
|
|
||||||
content.subQuestions = detected
|
|
||||||
// 移除被识别为子题的文本,保留选段/材料部分
|
|
||||||
const materialText = extractMaterialText(content.text, detected)
|
|
||||||
content.text = materialText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { id, type, score, content }
|
return { id, type, score, content }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 从文本中检测子题(如 "1.xxx", "2.xxx", "(1)xxx", "①xxx" 等)
|
|
||||||
* 返回检测到的子题列表(不含原文中的材料部分)
|
|
||||||
*
|
|
||||||
* 检测策略:
|
|
||||||
* 1. 优先识别带编号的行(1.xxx, (1)xxx, ①xxx 等)
|
|
||||||
* 2. 如果检测到编号子题,且其前一行带分值(如"xxx(3分)"),
|
|
||||||
* 则把前一行也作为子题1
|
|
||||||
*/
|
|
||||||
const detectSubQuestionsFromText = (
|
|
||||||
text: string
|
|
||||||
): Array<{ id: string; text: string; score?: number }> => {
|
|
||||||
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean)
|
|
||||||
const subQuestionPattern = /^(?:\(?(\d+)\)?|①|②|③|④|⑤|⑥|⑦|⑧|⑨|⑩)[.、))]?\s*(.+)/
|
|
||||||
const scorePattern = /[((](\d+)\s*分[))]/
|
|
||||||
const subs: Array<{ id: string; text: string; score?: number }> = []
|
|
||||||
|
|
||||||
// 先找所有带编号的子题
|
|
||||||
const numberedIndices: number[] = []
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
if (lines[i].match(subQuestionPattern)) {
|
|
||||||
numberedIndices.push(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (numberedIndices.length === 0) return []
|
|
||||||
|
|
||||||
// 如果第一个编号子题前面有带分值的行,把它们也作为子题
|
|
||||||
const firstNumberedIdx = numberedIndices[0]
|
|
||||||
if (firstNumberedIdx > 0) {
|
|
||||||
// 检查前一行是否带分值(可能是未编号的子题1)
|
|
||||||
for (let i = firstNumberedIdx - 1; i >= 0; i--) {
|
|
||||||
const line = lines[i]
|
|
||||||
const scoreMatch = line.match(scorePattern)
|
|
||||||
if (scoreMatch && line.length < 100) {
|
|
||||||
// 短行 + 带分值 = 可能是子题
|
|
||||||
subs.unshift({
|
|
||||||
id: createId(),
|
|
||||||
text: line,
|
|
||||||
score: Number(scoreMatch[1]),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理带编号的子题
|
|
||||||
let currentSub: { id: string; text: string } | null = null
|
|
||||||
for (let i = firstNumberedIdx; i < lines.length; i++) {
|
|
||||||
const line = lines[i]
|
|
||||||
const match = line.match(subQuestionPattern)
|
|
||||||
if (match) {
|
|
||||||
if (currentSub) subs.push(currentSub)
|
|
||||||
const content = match[2] || ""
|
|
||||||
const scoreMatch = content.match(scorePattern)
|
|
||||||
const score = scoreMatch ? Number(scoreMatch[1]) : undefined
|
|
||||||
currentSub = { id: createId(), text: content }
|
|
||||||
if (score !== undefined) {
|
|
||||||
subs.push({ ...currentSub, score })
|
|
||||||
currentSub = null
|
|
||||||
}
|
|
||||||
} else if (currentSub) {
|
|
||||||
currentSub.text += "\n" + line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (currentSub) subs.push(currentSub)
|
|
||||||
|
|
||||||
return subs
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从原文中提取材料文本(移除被识别为子题的部分)
|
|
||||||
*/
|
|
||||||
const extractMaterialText = (
|
|
||||||
fullText: string,
|
|
||||||
subs: Array<{ id: string; text: string }>
|
|
||||||
): string => {
|
|
||||||
let material = fullText
|
|
||||||
for (const sub of subs) {
|
|
||||||
// 移除子题文本(取第一行作为匹配依据)
|
|
||||||
const firstLine = sub.text.split("\n")[0]
|
|
||||||
if (firstLine) {
|
|
||||||
material = material.replace(firstLine, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 清理多余的空行
|
|
||||||
return material.replace(/\n{3,}/g, "\n\n").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 统计结构节点的题目数和总分(递归) */
|
/** 统计结构节点的题目数和总分(递归) */
|
||||||
const computeStats = (node: EditorStructureNode): { count: number; score: number } => {
|
const computeStats = (node: EditorStructureNode): { count: number; score: number } => {
|
||||||
if (node.type === "question") {
|
if (node.type === "question") {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const GroupBlock = Node.create({
|
|||||||
group: "block",
|
group: "block",
|
||||||
content: "block+",
|
content: "block+",
|
||||||
defining: true,
|
defining: true,
|
||||||
|
isolating: true,
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
title: { default: "" },
|
title: { default: "" },
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const QuestionBlock = Node.create({
|
|||||||
group: "block",
|
group: "block",
|
||||||
content: "block+",
|
content: "block+",
|
||||||
defining: true,
|
defining: true,
|
||||||
|
isolating: true,
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
questionId: { default: "" },
|
questionId: { default: "" },
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const SectionBlock = Node.create({
|
|||||||
group: "block",
|
group: "block",
|
||||||
content: "block+",
|
content: "block+",
|
||||||
defining: true,
|
defining: true,
|
||||||
|
isolating: true,
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
title: { default: "" },
|
title: { default: "" },
|
||||||
|
|||||||
@@ -151,40 +151,43 @@ export function SelectionToolbar({
|
|||||||
const insertQuestion = (type: QuestionBlockType) => {
|
const insertQuestion = (type: QuestionBlockType) => {
|
||||||
const score = type === "composite" ? 5 : 2
|
const score = type === "composite" ? 5 : 2
|
||||||
if (hasTextSelection) {
|
if (hasTextSelection) {
|
||||||
// 用 slice 获取选区内的完整节点结构(保留段落/列表/图片等),
|
// 先尝试 wrapIn(普通情况下可用)
|
||||||
// 然后用 questionBlock 包裹这些内容。这比 wrapIn 更可靠,
|
const wrapped = editor.commands.wrapInQuestion({ type, score })
|
||||||
// 因为 wrapIn 在选区跨越多个段落或部分段落时可能失败或丢失内容。
|
if (!wrapped) {
|
||||||
|
// wrapIn 失败(通常在 isolating 节点如复合题块内):
|
||||||
|
// 获取选中文本,删除选区,插入包含选中文本的 questionBlock
|
||||||
|
// 这样不会清空内容,而是把选中文本转为新的子题
|
||||||
const { from, to } = editor.state.selection
|
const { from, to } = editor.state.selection
|
||||||
const slice = editor.state.doc.slice(from, to)
|
const selectedText = editor.state.doc.textBetween(from, to, "\n")
|
||||||
const sliceContent = slice.content.toJSON
|
const lines = selectedText
|
||||||
? (slice.content.toJSON() as unknown[])
|
.split("\n")
|
||||||
: Array.isArray(slice.content.content)
|
.filter((l) => l.trim().length > 0)
|
||||||
? slice.content.content.map((n) => n.toJSON())
|
const content = lines.length > 0
|
||||||
: []
|
? lines.map((line) => ({
|
||||||
|
type: "paragraph",
|
||||||
// 确保 content: "block+" 满足(非空)
|
content: [{ type: "text", text: line }],
|
||||||
const validContent =
|
}))
|
||||||
sliceContent.length > 0
|
|
||||||
? sliceContent
|
|
||||||
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
|
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
|
||||||
|
|
||||||
editor.chain()
|
editor.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.deleteRange({ from, to })
|
.deleteSelection()
|
||||||
.insertContentAt(from, {
|
.insertContent({
|
||||||
type: "questionBlock",
|
type: "questionBlock",
|
||||||
attrs: { type, score, questionId: "" },
|
attrs: { type, score, questionId: "" },
|
||||||
content: validContent,
|
content,
|
||||||
})
|
})
|
||||||
.run()
|
.run()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 未选中文本时:插入空题目块
|
// 未选中文本时:插入空题目块
|
||||||
editor.chain().focus().insertQuestion({ type, score }).run()
|
editor.chain().focus().insertQuestion({ type, score }).run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 通用包裹:用 slice 获取选区完整节点结构,包裹到指定节点中 */
|
/** 通用降级包裹:wrapIn 失败时,把选中文本转为指定节点 */
|
||||||
const wrapSelection = (
|
const wrapOrInsert = (
|
||||||
|
wrapFn: () => boolean,
|
||||||
insertFn: () => void,
|
insertFn: () => void,
|
||||||
nodeType: "groupBlock" | "sectionBlock",
|
nodeType: "groupBlock" | "sectionBlock",
|
||||||
attrs: Record<string, unknown>,
|
attrs: Record<string, unknown>,
|
||||||
@@ -194,33 +197,34 @@ export function SelectionToolbar({
|
|||||||
insertFn()
|
insertFn()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 用 slice 获取选区内的完整节点结构,确保不丢失内容
|
const wrapped = wrapFn()
|
||||||
|
if (!wrapped) {
|
||||||
|
// wrapIn 失败:获取选中文本,删除选区,插入包含选中文本的节点
|
||||||
const { from, to } = editor.state.selection
|
const { from, to } = editor.state.selection
|
||||||
const slice = editor.state.doc.slice(from, to)
|
const selectedText = editor.state.doc.textBetween(from, to, "\n")
|
||||||
const sliceContent = slice.content.toJSON
|
const lines = selectedText.split("\n").filter((l) => l.trim().length > 0)
|
||||||
? (slice.content.toJSON() as unknown[])
|
const content = lines.length > 0
|
||||||
: Array.isArray(slice.content.content)
|
? lines.map((line) => ({
|
||||||
? slice.content.content.map((n) => n.toJSON())
|
type: "paragraph",
|
||||||
: []
|
content: [{ type: "text", text: line }],
|
||||||
|
}))
|
||||||
const validContent =
|
|
||||||
sliceContent.length > 0
|
|
||||||
? sliceContent
|
|
||||||
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
|
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
|
||||||
|
|
||||||
editor.chain()
|
editor.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.deleteRange({ from, to })
|
.deleteSelection()
|
||||||
.insertContentAt(from, {
|
.insertContent({
|
||||||
type: nodeType,
|
type: nodeType,
|
||||||
attrs: { ...attrs, title: defaultTitle },
|
attrs: { ...attrs, title: defaultTitle },
|
||||||
content: validContent,
|
content,
|
||||||
})
|
})
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const insertGroup = () => {
|
const insertGroup = () => {
|
||||||
wrapSelection(
|
wrapOrInsert(
|
||||||
|
() => editor.commands.wrapInGroup("一、选择题", ""),
|
||||||
() => editor.chain().focus().insertGroup("一、选择题", "").run(),
|
() => editor.chain().focus().insertGroup("一、选择题", "").run(),
|
||||||
"groupBlock",
|
"groupBlock",
|
||||||
{ instruction: "" },
|
{ instruction: "" },
|
||||||
@@ -229,7 +233,8 @@ export function SelectionToolbar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const insertSection = () => {
|
const insertSection = () => {
|
||||||
wrapSelection(
|
wrapOrInsert(
|
||||||
|
() => editor.commands.wrapInSection("第Ⅰ卷 选择题", 1),
|
||||||
() => editor.chain().focus().insertSection("第Ⅰ卷 选择题", 1).run(),
|
() => editor.chain().focus().insertSection("第Ⅰ卷 选择题", 1).run(),
|
||||||
"sectionBlock",
|
"sectionBlock",
|
||||||
{ level: 1 },
|
{ level: 1 },
|
||||||
|
|||||||
Reference in New Issue
Block a user