Skip to content

Commit 7ca55f5

Browse files
fix(preset-gfm): incorrect table parsing when pasting from Google Docs (#2283)
* fix(preset-gfm): incorrect table parsing when pasting from Google Docs Google Docs tables use only <td>, never <th>. ProseMirror auto-inserts an empty table_header_row, resulting in an extra row with empty cells. Promote the first data row to header when there are 2+ data rows. For single-row tables, keep existing fallback (empty header cells). Also add recursive traversal in the paste rule so tables nested inside wrapper nodes are found and fixed. Closes #2259 * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 59104a1 commit 7ca55f5

2 files changed

Lines changed: 118 additions & 21 deletions

File tree

e2e/tests/crepe/table.spec.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,11 +155,10 @@ const googleDocsTableWithoutHeader = `
155155
`.trim()
156156

157157
const expectedMarkdown = `
158-
| <br /> | <br /> | <br /> |
159-
| :----- | :----- | :----- |
160-
| 111 | 222 | 333 |
161-
| 444 | 555 | 666 |
162-
| 777 | aaa | bbb |
158+
| 111 | 222 | 333 |
159+
| :-- | :-- | :-- |
160+
| 444 | 555 | 666 |
161+
| 777 | aaa | bbb |
163162
`.trim()
164163

165164
test('paste missing header tables', async ({ page }) => {
@@ -177,3 +176,62 @@ test('paste missing header tables', async ({ page }) => {
177176
await page.keyboard.type('First Header Cell')
178177
await expect(firstCell).toContainText('First Header Cell')
179178
})
179+
180+
const googleDocsThreeRowTable = `
181+
<meta charset='utf-8'><meta charset="utf-8"><b style="font-weight:normal;" id="docs-internal-guid-test"><div dir="ltr" style="margin-left:0pt;" align="left"><table style="border:none;border-collapse:collapse;table-layout:fixed;width:468pt"><colgroup><col /><col /><col /></colgroup><tbody><tr style="height:0pt"><td style="border:solid #000000 1pt;vertical-align:top;padding:5pt;"><p dir="ltr"><span>A1</span></p></td><td style="border:solid #000000 1pt;vertical-align:top;padding:5pt;"><p dir="ltr"><span>B1</span></p></td><td style="border:solid #000000 1pt;vertical-align:top;padding:5pt;"><p dir="ltr"><span>C1</span></p></td></tr><tr style="height:0pt"><td style="border:solid #000000 1pt;vertical-align:top;padding:5pt;"><p dir="ltr"><span>A2</span></p></td><td style="border:solid #000000 1pt;vertical-align:top;padding:5pt;"><p dir="ltr"><span>B2</span></p></td><td style="border:solid #000000 1pt;vertical-align:top;padding:5pt;"><p dir="ltr"><span>C2</span></p></td></tr><tr style="height:0pt"><td style="border:solid #000000 1pt;vertical-align:top;padding:5pt;"><p dir="ltr"><span>A3</span></p></td><td style="border:solid #000000 1pt;vertical-align:top;padding:5pt;"><p dir="ltr"><span>B3</span></p></td><td style="border:solid #000000 1pt;vertical-align:top;padding:5pt;"><p dir="ltr"><span>C3</span></p></td></tr></tbody></table></div></b>
182+
`.trim()
183+
184+
test('paste google docs table promotes first row to header', async ({
185+
page,
186+
}) => {
187+
await focusEditor(page)
188+
await paste(page, {
189+
'text/html': googleDocsThreeRowTable,
190+
})
191+
await waitNextFrame(page)
192+
const md = await getMarkdown(page)
193+
expect(md.includes('| A1 | B1 | C1 |')).toBeTruthy()
194+
expect(md.includes('| A2 | B2 | C2 |')).toBeTruthy()
195+
expect(md.includes('| A3 | B3 | C3 |')).toBeTruthy()
196+
// First row should be header, no empty <br /> headers
197+
expect(md.includes('<br />')).toBeFalsy()
198+
})
199+
200+
test('paste google docs table promotes first row even when nested in wrapper', async ({
201+
page,
202+
}) => {
203+
// Google Docs wraps tables in <b><div>...<table>...</table>...</div></b>
204+
// This tests that the paste rule traverses into wrapper nodes
205+
await focusEditor(page)
206+
await paste(page, {
207+
'text/html': googleDocsTableWithoutHeader,
208+
})
209+
await waitNextFrame(page)
210+
const md = await getMarkdown(page)
211+
// The table from googleDocsTableWithoutHeader is nested inside <b><div>
212+
// The paste rule should still find and fix it
213+
expect(md.includes('| 111 | 222 | 333 |')).toBeTruthy()
214+
expect(md.includes('| 444 | 555 | 666 |')).toBeTruthy()
215+
expect(md.includes('| 777 | aaa | bbb |')).toBeTruthy()
216+
// First row should be promoted to header, no empty <br /> headers
217+
expect(md.includes('<br />')).toBeFalsy()
218+
})
219+
220+
const googleDocsSingleRowTable = `
221+
<meta charset='utf-8'><meta charset="utf-8"><b style="font-weight:normal;" id="docs-internal-guid-single"><div dir="ltr" style="margin-left:0pt;" align="left"><table style="border:none;border-collapse:collapse;table-layout:fixed;width:468pt"><colgroup><col /><col /></colgroup><tbody><tr style="height:0pt"><td style="border:solid #000000 1pt;vertical-align:top;padding:5pt;"><p dir="ltr"><span>Only1</span></p></td><td style="border:solid #000000 1pt;vertical-align:top;padding:5pt;"><p dir="ltr"><span>Only2</span></p></td></tr></tbody></table></div></b>
222+
`.trim()
223+
224+
test('paste single row google docs table keeps empty header', async ({
225+
page,
226+
}) => {
227+
await focusEditor(page)
228+
await paste(page, {
229+
'text/html': googleDocsSingleRowTable,
230+
})
231+
await waitNextFrame(page)
232+
const md = await getMarkdown(page)
233+
// Single row can't be promoted (would leave 0 data rows), so empty header is kept
234+
expect(md.includes('<br />')).toBeTruthy()
235+
expect(md.includes('Only1')).toBeTruthy()
236+
expect(md.includes('Only2')).toBeTruthy()
237+
})

packages/plugins/preset-gfm/src/node/table/input.ts

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { commandsCtx } from '@milkdown/core'
22
import { paragraphSchema } from '@milkdown/preset-commonmark'
33
import { InputRule } from '@milkdown/prose/inputrules'
4-
import { Fragment, Slice } from '@milkdown/prose/model'
4+
import {
5+
type Fragment as FragmentType,
6+
Fragment,
7+
type Node as ProsemirrorNode,
8+
Slice,
9+
} from '@milkdown/prose/model'
510
import { TextSelection } from '@milkdown/prose/state'
611
import { $inputRule, $pasteRule, $useKeymap } from '@milkdown/utils'
712

@@ -52,35 +57,53 @@ withMeta(insertTableInputRule, {
5257
/// A paste rule for fixing tables without header cells.
5358
/// This is a workaround for some editors (e.g. Google Docs) which allow creating tables without header cells,
5459
/// which is not supported by Markdown schema.
55-
/// This paste rule will add header cells to the first row if it's missing.
60+
/// This paste rule will promote the first data row to header, or add empty header cells as a fallback.
5661
export const tablePasteRule = $pasteRule((ctx) => ({
5762
run: (slice, _view, isPlainText) => {
5863
if (isPlainText) {
5964
return slice
6065
}
61-
let fragment = slice.content
6266

63-
slice.content.forEach((node, _offset, index) => {
64-
if (node?.type !== tableSchema.type(ctx)) {
65-
return
66-
}
67+
function fixTable(node: ProsemirrorNode): ProsemirrorNode {
6768
const rowsCount = node.childCount
6869
const colsCount = node.lastChild?.childCount ?? 0
6970
if (rowsCount === 0 || colsCount === 0) {
70-
fragment = fragment.replaceChild(
71-
index,
72-
paragraphSchema.type(ctx).create()
73-
)
74-
return
71+
return paragraphSchema.type(ctx).create()
7572
}
7673

7774
const headerRow = node.firstChild
7875
const needToFixHeaderRow =
7976
colsCount > 0 && headerRow && headerRow.childCount === 0
8077
if (!needToFixHeaderRow) {
81-
return
78+
return node
79+
}
80+
81+
// If there are 2+ data rows (3+ total: empty header + 2+ data rows),
82+
// promote the first data row to header
83+
if (rowsCount >= 3) {
84+
const firstDataRow = node.child(1)
85+
const headerCells: ProsemirrorNode[] = []
86+
for (let i = 0; i < firstDataRow.childCount; i++) {
87+
const cell = firstDataRow.child(i)
88+
headerCells.push(
89+
tableHeaderSchema
90+
.type(ctx)
91+
.create(cell.attrs, cell.content, cell.marks)
92+
)
93+
}
94+
const newHeaderRow = headerRow.type.create(headerRow.attrs, headerCells)
95+
96+
// Collect remaining data rows (skip promoted row at index 1)
97+
const remainingRows: ProsemirrorNode[] = []
98+
for (let i = 2; i < rowsCount; i++) {
99+
remainingRows.push(node.child(i))
100+
}
101+
102+
return node.type.create(node.attrs, [newHeaderRow, ...remainingRows])
82103
}
83-
// Fix for tables with rows but no cells in the first row
104+
105+
// Fallback: only 1 data row, can't promote (would leave 0 data rows).
106+
// Fill the empty header with blank cells.
84107
const headerCells = Array(colsCount)
85108
.fill(0)
86109
.map(() => tableHeaderSchema.type(ctx).createAndFill()!)
@@ -93,9 +116,25 @@ export const tablePasteRule = $pasteRule((ctx) => ({
93116
headerRow.nodeSize,
94117
new Slice(Fragment.from(newHeaderRow), 0, 0)
95118
)
96-
fragment = fragment.replaceChild(index, newTable)
97-
})
119+
return newTable
120+
}
121+
122+
function fixFragment(fragment: FragmentType): FragmentType {
123+
let result = fragment
124+
fragment.forEach((node, _offset, index) => {
125+
if (node.type === tableSchema.type(ctx)) {
126+
result = result.replaceChild(index, fixTable(node))
127+
} else if (node.childCount > 0) {
128+
const fixedContent = fixFragment(node.content)
129+
if (fixedContent !== node.content) {
130+
result = result.replaceChild(index, node.copy(fixedContent))
131+
}
132+
}
133+
})
134+
return result
135+
}
98136

137+
const fragment = fixFragment(slice.content)
99138
return new Slice(Fragment.from(fragment), slice.openStart, slice.openEnd)
100139
},
101140
}))

0 commit comments

Comments
 (0)