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:
@@ -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
|
||||
// 这样不会清空内容,而是把选中文本转为新的子题
|
||||
// 用 slice 获取选区内的完整节点结构(保留段落/列表/图片等),
|
||||
// 然后用 questionBlock 包裹这些内容。这比 wrapIn 更可靠,
|
||||
// 因为 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 }],
|
||||
}))
|
||||
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({
|
||||
.deleteRange({ from, to })
|
||||
.insertContentAt(from, {
|
||||
type: "questionBlock",
|
||||
attrs: { type, score, questionId: "" },
|
||||
content,
|
||||
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 失败:获取选中文本,删除选区,插入包含选中文本的节点
|
||||
// 用 slice 获取选区内的完整节点结构,确保不丢失内容
|
||||
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 }],
|
||||
}))
|
||||
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({
|
||||
.deleteRange({ from, to })
|
||||
.insertContentAt(from, {
|
||||
type: nodeType,
|
||||
attrs: { ...attrs, title: defaultTitle },
|
||||
content,
|
||||
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 },
|
||||
|
||||
Reference in New Issue
Block a user