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