From 064b3cf736cc6df64b0af1cd87e0b8db6e80b237 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:37:01 +0800 Subject: [PATCH] fix(exams): use slice to preserve full content when wrapping selections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../exams/editor/selection-toolbar.tsx | 103 +++++++++--------- 1 file changed, 49 insertions(+), 54 deletions(-) diff --git a/src/modules/exams/editor/selection-toolbar.tsx b/src/modules/exams/editor/selection-toolbar.tsx index 746eb66..0face48 100644 --- a/src/modules/exams/editor/selection-toolbar.tsx +++ b/src/modules/exams/editor/selection-toolbar.tsx @@ -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, @@ -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 },