diff --git a/.changeset/three-squids-behave.md b/.changeset/three-squids-behave.md new file mode 100644 index 00000000..892e3ea7 --- /dev/null +++ b/.changeset/three-squids-behave.md @@ -0,0 +1,8 @@ +--- +"streamdown": minor +--- + +- Fixed table copy losing line breaks inside cells containing `` elements. +- Preserved multiline content during table data extraction. +- Improved copied Markdown, CSV, and TSV output consistency for multiline cells. +- Added coverage for `` handling in table extraction. diff --git a/packages/streamdown/__tests__/copy-dropdown.test.tsx b/packages/streamdown/__tests__/copy-dropdown.test.tsx index b2dc25af..f65cb9bc 100644 --- a/packages/streamdown/__tests__/copy-dropdown.test.tsx +++ b/packages/streamdown/__tests__/copy-dropdown.test.tsx @@ -615,4 +615,218 @@ describe("TableCopyDropdown", () => { expect.any(Function) ); }); + + describe("line breaks inside table cells", () => { + const OriginalBlob = globalThis.Blob; + let blobPartsByType: Record; + let blobSpy: ReturnType; + + const getClipboardPlainText = (): string => { + const plainTextParts = blobPartsByType["text/plain"]; + return String(plainTextParts?.[0] ?? ""); + }; + + beforeEach(() => { + blobPartsByType = {}; + blobSpy = vi.spyOn(globalThis, "Blob").mockImplementation(function ( + this: Blob, + parts?: BlobPart[], + opts?: BlobPropertyBag + ) { + const type = opts?.type ?? ""; + if (parts) { + blobPartsByType[type] = parts; + } + return new OriginalBlob(parts, opts); + } as unknown as typeof Blob); + + mockTable.innerHTML = ` + + + Description + Notes + + + + + Paragraph one.Paragraph two. + Line ALine BLine C + + + `; + }); + + afterEach(() => { + blobSpy.mockRestore(); + }); + + it("should preserve as newlines when copying as Markdown", async () => { + const dropdownContainer = document.createElement("div"); + mockWrapper.appendChild(dropdownContainer); + + const { container, getByText } = render( + + + , + { container: dropdownContainer } + ); + + const button = container.querySelector('button[title="Copy table"]'); + expect(button).toBeTruthy(); + if (button) { + fireEvent.click(button); + } + + fireEvent.click(getByText("Markdown")); + + await waitFor(() => { + expect(navigator.clipboard.write).toHaveBeenCalled(); + }); + + expect(blobPartsByType["text/plain"]).toBeDefined(); + + expect(getClipboardPlainText()).toBe( + "| Description | Notes |\n| --- | --- |\n| Paragraph one.Paragraph two. | Line ALine BLine C |" + ); + + mockWrapper.removeChild(dropdownContainer); + }); + + it("should preserve as newlines when copying as CSV", async () => { + const dropdownContainer = document.createElement("div"); + mockWrapper.appendChild(dropdownContainer); + + const { container, getByText } = render( + + + , + { container: dropdownContainer } + ); + + const button = container.querySelector('button[title="Copy table"]'); + expect(button).toBeTruthy(); + if (button) { + fireEvent.click(button); + } + + fireEvent.click(getByText("CSV")); + + await waitFor(() => { + expect(navigator.clipboard.write).toHaveBeenCalled(); + }); + + expect(blobPartsByType["text/plain"]).toBeDefined(); + + expect(getClipboardPlainText()).toBe( + 'Description,Notes\n"Paragraph one.\nParagraph two.","Line A\nLine B\nLine C"' + ); + + mockWrapper.removeChild(dropdownContainer); + }); + + it("should preserve as escaped newlines when copying as TSV", async () => { + const dropdownContainer = document.createElement("div"); + mockWrapper.appendChild(dropdownContainer); + + const { container, getByText } = render( + + + , + { container: dropdownContainer } + ); + + const button = container.querySelector('button[title="Copy table"]'); + expect(button).toBeTruthy(); + if (button) { + fireEvent.click(button); + } + + fireEvent.click(getByText("TSV")); + + await waitFor(() => { + expect(navigator.clipboard.write).toHaveBeenCalled(); + }); + + expect(blobPartsByType["text/plain"]).toBeDefined(); + + expect(getClipboardPlainText()).toBe( + "Description\tNotes\nParagraph one.\\nParagraph two.\tLine A\\nLine B\\nLine C" + ); + + mockWrapper.removeChild(dropdownContainer); + }); + + it("should not merge cell text across tags", async () => { + mockTable.innerHTML = ` + + + Cell + + + + + Paragraph one.Paragraph two. + + + `; + + const dropdownContainer = document.createElement("div"); + mockWrapper.appendChild(dropdownContainer); + + const { container, getByText } = render( + + + , + { container: dropdownContainer } + ); + + const button = container.querySelector('button[title="Copy table"]'); + expect(button).toBeTruthy(); + if (button) { + fireEvent.click(button); + } + + fireEvent.click(getByText("CSV")); + await waitFor(() => { + expect(navigator.clipboard.write).toHaveBeenCalled(); + }); + + expect(blobPartsByType["text/plain"]).toBeDefined(); + + const plainText = getClipboardPlainText(); + + expect(plainText).not.toContain("Paragraph one.Paragraph two."); + expect(plainText).toContain("Paragraph one.\nParagraph two."); + + mockWrapper.removeChild(dropdownContainer); + }); + }); }); diff --git a/packages/streamdown/__tests__/table-utils.test.ts b/packages/streamdown/__tests__/table-utils.test.ts index 761f6c91..5ad2409f 100644 --- a/packages/streamdown/__tests__/table-utils.test.ts +++ b/packages/streamdown/__tests__/table-utils.test.ts @@ -119,6 +119,64 @@ describe("Table Utils", () => { expect(result.headers).toEqual([]); expect(result.rows).toEqual([["Data"]]); }); + + it("should convert elements to newlines in cells", () => { + tableElement.innerHTML = ` + + + Header + + + + + Paragraph one.Paragraph two. + + + `; + + const result = extractTableDataFromElement(tableElement); + + expect(result.rows).toEqual([["Paragraph one.\nParagraph two."]]); + }); + + it("should convert multiple elements to newlines", () => { + tableElement.innerHTML = ` + + + Notes + + + + + Line ALine BLine C + + + `; + + const result = extractTableDataFromElement(tableElement); + + expect(result.rows).toEqual([["Line A\nLine B\nLine C"]]); + }); + + it("should convert inside nested elements to newlines", () => { + tableElement.innerHTML = ` + + + Headerwith break + + + + + BoldItalic + + + `; + + const result = extractTableDataFromElement(tableElement); + + expect(result.headers).toEqual(["Header\nwith break"]); + expect(result.rows).toEqual([["Bold\nItalic"]]); + }); }); describe("tableDataToCSV", () => { @@ -290,6 +348,11 @@ describe("Table Utils", () => { const result = escapeMarkdownTableCell(""); expect(result).toBe(""); }); + + it("should convert newlines to ", () => { + const result = escapeMarkdownTableCell("Paragraph one.\nParagraph two."); + expect(result).toBe("Paragraph one.Paragraph two."); + }); }); describe("tableDataToMarkdown", () => { @@ -378,5 +441,18 @@ describe("Table Utils", () => { // Extra cells are ignored during the mapping expect(result).toBe("| Col1 | Col2 |\n| --- | --- |\n| A | B | C | D |"); }); + + it("should convert cell newlines to for single-line table rows", () => { + const data: TableData = { + headers: ["Description"], + rows: [["Paragraph one.\nParagraph two."]], + }; + + const result = tableDataToMarkdown(data); + + expect(result).toBe( + "| Description |\n| --- |\n| Paragraph one.Paragraph two. |" + ); + }); }); }); diff --git a/packages/streamdown/lib/table/utils.ts b/packages/streamdown/lib/table/utils.ts index 13c98cf8..81fc6d25 100644 --- a/packages/streamdown/lib/table/utils.ts +++ b/packages/streamdown/lib/table/utils.ts @@ -3,6 +3,24 @@ export interface TableData { rows: string[][]; } +function extractCellText(node: Node): string { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent ?? ""; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return ""; + } + + const element = node as HTMLElement; + + if (element.tagName === "BR") { + return "\n"; + } + + return Array.from(element.childNodes).map(extractCellText).join(""); +} + export const extractTableDataFromElement = ( tableElement: HTMLElement ): TableData => { @@ -12,7 +30,7 @@ export const extractTableDataFromElement = ( // Extract headers const headerCells = tableElement.querySelectorAll("thead th"); for (const cell of headerCells) { - headers.push(cell.textContent?.trim() || ""); + headers.push(extractCellText(cell).trim()); } // Extract rows @@ -21,7 +39,7 @@ export const extractTableDataFromElement = ( const rowData: string[] = []; const cells = row.querySelectorAll("td"); for (const cell of cells) { - rowData.push(cell.textContent?.trim() || ""); + rowData.push(extractCellText(cell).trim()); } rows.push(rowData); } @@ -139,7 +157,7 @@ export const escapeMarkdownTableCell = (cell: string): string => { // OPTIMIZATION: Fast path for cells that don't need escaping - check chars directly let needsEscaping = false; for (const char of cell) { - if (char === "\\" || char === "|") { + if (char === "\\" || char === "|" || char === "\n") { needsEscaping = true; break; } @@ -149,13 +167,14 @@ export const escapeMarkdownTableCell = (cell: string): string => { return cell; } - // OPTIMIZATION: Use array building instead of string concatenation for better performance const parts: string[] = []; for (const char of cell) { if (char === "\\") { parts.push("\\\\"); } else if (char === "|") { parts.push("\\|"); + } else if (char === "\n") { + parts.push(""); } else { parts.push(char); }