Przeglądaj źródła

fix(editor): restore table rendering and correct boundary math in buildCombinedText

- Rewrite buildCombinedText with dynamic separator logic: \n between
  table rows (so GFM recognizes full tables), \n\n elsewhere (so
  tasks/callouts remain separate paragraphs)
- Fix boundary calculation bug: separator characters must be included
  in the offset boundaries or resolveOffset maps Lezer offsets to
  wrong paragraphs (causing truncated headings, shifted bold ranges)
- Add regression tests for multi-paragraph heading positioning and
  cross-paragraph table recognition through the decoration pipeline
Zander Hawke 4 dni temu
rodzic
commit
242e028a81

+ 56 - 0
packages/editor/src/composables/__tests__/markdown-decorations.test.ts

@@ -270,4 +270,60 @@ describe("Markdown decorations plugin", () => {
     const mathCount = decorations?.find().length ?? 0
     expect(mathCount).toBeGreaterThan(0)
   })
+
+  it("positions heading content correctly when heading is followed by blank line", () => {
+    const { plugin } = createMarkdownDecorationsPlugin()
+    // contentToDoc splits on \n: heading, blank, text = 3 paragraphs
+    const doc = contentToDoc("# Hello World\n\nSome text")
+
+    const editorState = EditorState.create({
+      doc,
+      plugins: [plugin],
+    })
+
+    const decorations = plugin.spec.props?.decorations?.(editorState)
+    const found = decorations?.find() ?? []
+
+    const headingContent = found.find(
+      (d) =>
+        typeof d.type === "object" &&
+        d.type.attrs &&
+        typeof d.type.attrs.class === "string" &&
+        (d.type.attrs.class as string).includes("md-heading-content"),
+    )
+
+    expect(headingContent).toBeDefined()
+    if (headingContent) {
+      // Para 0: "# Hello World" (13 chars) at PM positions [1, 14)
+      // Heading content "Hello World" starts at PM pos 3, ends at 14
+      expect(headingContent.from).toBe(3)
+      expect(headingContent.to).toBe(14)
+    }
+  })
+
+  it("recognises table across multiple PM paragraphs", () => {
+    const { plugin } = createMarkdownDecorationsPlugin()
+    // Each line becomes a PM paragraph; buildCombinedText must join
+    // table rows with \n (not \n\n) so GFM sees a single Table node.
+    const doc = contentToDoc("| A | B |\n|---|---|\n| 1 | 2 |")
+
+    const editorState = EditorState.create({
+      doc,
+      plugins: [plugin],
+    })
+
+    const decorations = plugin.spec.props?.decorations?.(editorState)
+    const found = decorations?.find() ?? []
+
+    // Table wrapper uses "md-table" class
+    const tableDecoration = found.find(
+      (d) =>
+        typeof d.type === "object" &&
+        d.type.attrs &&
+        typeof d.type.attrs.class === "string" &&
+        (d.type.attrs.class as string).includes("md-table"),
+    )
+
+    expect(tableDecoration).toBeDefined()
+  })
 })

+ 44 - 9
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -107,24 +107,59 @@ function collectParagraphs(doc: ProsemirrorNode): ParagraphInfo[] {
 }
 
 /**
- * Build the combined text (joined by \n\n) and boundary offsets.
+ * Check if text looks like a GFM table row (starts with |, contains more |).
+ */
+function isTableRow(text: string): boolean {
+  const trimmed = text.trim()
+  return trimmed.startsWith("|") && trimmed.indexOf("|", 1) !== -1
+}
+
+/**
+ * Check if text is a GFM table delimiter row (e.g., |---|---|).
+ */
+function isTableDelimiter(text: string): boolean {
+  const trimmed = text.trim()
+  if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) return false
+  const inner = trimmed.slice(1, -1).trim()
+  return /^[-:| ]+$/.test(inner) && inner.includes("---")
+}
+
+/**
+ * Determine the separator between paragraph i and i+1.
+ * Table rows stay joined with \n so GFM recognizes the full table.
+ * Everything else uses \n\n so GFM sees them as separate paragraphs
+ * (needed for task/callout detection).
+ */
+function separatorBetween(paragraphs: ParagraphInfo[], i: number): string {
+  if (i >= paragraphs.length - 1) return ""
+  const cur = paragraphs[i]!.text.trim()
+  const nxt = paragraphs[i + 1]!.text.trim()
+  if (isTableRow(cur) && (isTableDelimiter(nxt) || isTableRow(nxt))) return "\n"
+  if (isTableDelimiter(cur) && isTableRow(nxt)) return "\n"
+  return "\n\n"
+}
+
+/**
+ * Build the combined text and paragraph boundary offsets.
+ * Uses dynamic separators: \n between table rows, \n\n elsewhere.
  * boundaries[0] = 0, boundaries[i] = start offset of paragraph i,
- * boundaries[N] = total length (last para end, no trailing \n).
+ * boundaries[N] = total length (last para end, no trailing separator).
  */
 function buildCombinedText(paragraphs: ParagraphInfo[]): {
   text: string
   boundaries: number[]
 } {
-  const parts: string[] = []
   const boundaries: number[] = [0]
+  let text = ""
   for (let i = 0; i < paragraphs.length; i++) {
-    parts.push(paragraphs[i]!.text)
-    const sepLen = i < paragraphs.length - 1 ? 2 : 1
-    boundaries.push(
-      boundaries[boundaries.length - 1]! + paragraphs[i]!.text.length + sepLen,
-    )
+    text += paragraphs[i]!.text
+    const sepLen = i < paragraphs.length - 1 ? separatorBetween(paragraphs, i).length : 1
+    boundaries.push(text.length + sepLen)
+    if (i < paragraphs.length - 1) {
+      text += separatorBetween(paragraphs, i)
+    }
   }
-  return { text: parts.join("\n\n"), boundaries }
+  return { text, boundaries }
 }
 
 /**