fix(exams): use slice to preserve full content when wrapping selections

Root cause: wrapIn (Tiptap's built-in command) can fail or lose content
when the selection spans multiple paragraphs or partial paragraphs. When
users selected a long reading passage and clicked "复合" (composite),
the content was destroyed — only fragments remained.

Fix: replace wrapIn with a manual slice-based approach:
1. Use doc.slice(from, to) to get the complete node structure of the
   selection (preserves paragraphs, lists, images, etc.)
2. deleteRange to remove the original selection
3. insertContentAt to insert a new questionBlock/groupBlock/sectionBlock
   containing the sliced content

This is more reliable than wrapIn because it doesn't depend on
ProseMirror's wrapping logic, which has edge cases with multi-paragraph
selections. The slice API captures the exact node structure, so no
content is lost.

Applied to all three wrapping operations:
- insertQuestion (questionBlock)
- insertGroup (groupBlock)
- insertSection (sectionBlock)
This commit is contained in:
SpecialX
2026-06-24 14:37:01 +08:00
parent 2562de76b7
commit 064b3cf736

View File

@@ -151,43 +151,40 @@ export function SelectionToolbar({
const insertQuestion = (type: QuestionBlockType) => {
const score = type === "composite" ? 5 : 2
if (hasTextSelection) {
// 先尝试 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 }],
}))
// 用 slice 获取选区内的完整节点结构(保留段落/列表/图片等),
// 然后用 questionBlock 包裹这些内容。这比 wrapIn 更可靠,
// 因为 wrapIn 在选区跨越多个段落或部分段落时可能失败或丢失内容。
const { from, to } = editor.state.selection
const slice = editor.state.doc.slice(from, to)
const sliceContent = slice.content.toJSON
? (slice.content.toJSON() as unknown[])
: Array.isArray(slice.content.content)
? slice.content.content.map((n) => n.toJSON())
: []
// 确保 content: "block+" 满足(非空)
const validContent =
sliceContent.length > 0
? sliceContent
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
editor.chain()
.focus()
.deleteSelection()
.insertContent({
type: "questionBlock",
attrs: { type, score, questionId: "" },
content,
})
.run()
}
editor.chain()
.focus()
.deleteRange({ from, to })
.insertContentAt(from, {
type: "questionBlock",
attrs: { type, score, questionId: "" },
content: validContent,
})
.run()
} else {
// 未选中文本时:插入空题目块
editor.chain().focus().insertQuestion({ type, score }).run()
}
}
/** 通用降级包裹:wrapIn 失败时,把选中文本转为指定节点 */
const wrapOrInsert = (
wrapFn: () => boolean,
/** 通用包裹:用 slice 获取选区完整节点结构,包裹到指定节点 */
const wrapSelection = (
insertFn: () => void,
nodeType: "groupBlock" | "sectionBlock",
attrs: Record<string, unknown>,
@@ -197,34 +194,33 @@ export function SelectionToolbar({
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 }],
}))
// 用 slice 获取选区内的完整节点结构,确保不丢失内容
const { from, to } = editor.state.selection
const slice = editor.state.doc.slice(from, to)
const sliceContent = slice.content.toJSON
? (slice.content.toJSON() as unknown[])
: Array.isArray(slice.content.content)
? slice.content.content.map((n) => n.toJSON())
: []
const validContent =
sliceContent.length > 0
? sliceContent
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
editor.chain()
.focus()
.deleteSelection()
.insertContent({
type: nodeType,
attrs: { ...attrs, title: defaultTitle },
content,
})
.run()
}
editor.chain()
.focus()
.deleteRange({ from, to })
.insertContentAt(from, {
type: nodeType,
attrs: { ...attrs, title: defaultTitle },
content: validContent,
})
.run()
}
const insertGroup = () => {
wrapOrInsert(
() => editor.commands.wrapInGroup("一、选择题", ""),
wrapSelection(
() => editor.chain().focus().insertGroup("一、选择题", "").run(),
"groupBlock",
{ instruction: "" },
@@ -233,8 +229,7 @@ export function SelectionToolbar({
}
const insertSection = () => {
wrapOrInsert(
() => editor.commands.wrapInSection("第Ⅰ卷 选择题", 1),
wrapSelection(
() => editor.chain().focus().insertSection("第Ⅰ卷 选择题", 1).run(),
"sectionBlock",
{ level: 1 },