fix(exams): preserve selected text when wrapIn fails in isolating nodes

When wrapIn fails inside isolating nodes (e.g. composite question block),
the previous fallback used insertContent which replaced the entire
selection with an empty questionBlock, causing other sub-questions to
disappear and content to be cleared.

New approach: when wrapIn fails, extract the selected text, delete the
selection, then insert a new node (questionBlock/groupBlock/sectionBlock)
containing the selected text as paragraphs. This preserves the content
and converts it into the desired structure.

Applied to all three wrap operations:
- insertQuestion (questionBlock)
- insertGroup (groupBlock)
- insertSection (sectionBlock)
This commit is contained in:
SpecialX
2026-06-24 14:24:04 +08:00
parent df9561128b
commit ccf6c03096

View File

@@ -149,37 +149,97 @@ export function SelectionToolbar({
const hasTextSelection = !empty && from !== to
const insertQuestion = (type: QuestionBlockType) => {
const chain = editor.chain().focus()
const score = type === "composite" ? 5 : 2
if (hasTextSelection) {
// 选中文本时:包裹为题目块
// 注意:在 isolating 节点(如复合题)内,wrapIn 可能失败,
// 此时降级为插入空题目块
const success = chain.wrapInQuestion({ type, score: type === "composite" ? 5 : 2 }).run()
if (!success) {
chain.insertQuestion({ type, score: type === "composite" ? 5 : 2 }).run()
// 先尝试 wrapIn(普通情况下可用)
const wrapped = editor.commands.wrapInQuestion({ type, score })
if (!wrapped) {
// wrapIn 失败(通常在 isolating 节点如复合题块内):
// 获取选中文本,删除选区,插入包含选中文本的 questionBlock
// 这样不会清空内容,而是把选中文本转为新的子题
const { from, to } = editor.state.selection
const selectedText = editor.state.doc.textBetween(from, to, "\n")
const lines = selectedText
.split("\n")
.filter((l) => l.trim().length > 0)
const content = lines.length > 0
? lines.map((line) => ({
type: "paragraph",
content: [{ type: "text", text: line }],
}))
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
editor.chain()
.focus()
.deleteSelection()
.insertContent({
type: "questionBlock",
attrs: { type, score, questionId: "" },
content,
})
.run()
}
} else {
// 未选中文本时:插入空题目块
chain.insertQuestion({ type, score: type === "composite" ? 5 : 2 }).run()
editor.chain().focus().insertQuestion({ type, score }).run()
}
}
/** 通用降级包裹:wrapIn 失败时,把选中文本转为指定节点 */
const wrapOrInsert = (
wrapFn: () => boolean,
insertFn: () => void,
nodeType: "groupBlock" | "sectionBlock",
attrs: Record<string, unknown>,
defaultTitle: string
) => {
if (!hasTextSelection) {
insertFn()
return
}
const wrapped = wrapFn()
if (!wrapped) {
// wrapIn 失败:获取选中文本,删除选区,插入包含选中文本的节点
const { from, to } = editor.state.selection
const selectedText = editor.state.doc.textBetween(from, to, "\n")
const lines = selectedText.split("\n").filter((l) => l.trim().length > 0)
const content = lines.length > 0
? lines.map((line) => ({
type: "paragraph",
content: [{ type: "text", text: line }],
}))
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
editor.chain()
.focus()
.deleteSelection()
.insertContent({
type: nodeType,
attrs: { ...attrs, title: defaultTitle },
content,
})
.run()
}
}
const insertGroup = () => {
const chain = editor.chain().focus()
if (hasTextSelection) {
chain.wrapInGroup("一、选择题", "").run()
} else {
chain.insertGroup("一、选择题", "").run()
}
wrapOrInsert(
() => editor.commands.wrapInGroup("一、选择题", ""),
() => editor.chain().focus().insertGroup("一、选择题", "").run(),
"groupBlock",
{ instruction: "" },
"一、选择题"
)
}
const insertSection = () => {
const chain = editor.chain().focus()
if (hasTextSelection) {
chain.wrapInSection("第Ⅰ卷 选择题", 1).run()
} else {
chain.insertSection("第Ⅰ卷 选择题", 1).run()
}
wrapOrInsert(
() => editor.commands.wrapInSection("第Ⅰ卷 选择题", 1),
() => editor.chain().focus().insertSection("第Ⅰ卷 选择题", 1).run(),
"sectionBlock",
{ level: 1 },
"第Ⅰ卷 选择题"
)
}
const toggleDotted = () => {