@milkdown/preset-gfm 7.19.0 → 7.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import {
3
+ defaultValueCtx,
4
+ Editor,
5
+ editorViewCtx,
6
+ schemaCtx,
7
+ serializerCtx,
8
+ } from '@milkdown/core'
9
+ import { commonmark } from '@milkdown/preset-commonmark'
10
+ import { expect, it } from 'vitest'
11
+
12
+ import { gfm } from '..'
13
+
14
+ function createEditor() {
15
+ const editor = Editor.make()
16
+ editor.use(commonmark)
17
+ editor.use(gfm)
18
+ return editor
19
+ }
20
+
21
+ it('should not crash when serializing a table with empty header row', async () => {
22
+ const markdown = `| head |\n| --- |\n| cell |\n`
23
+ const editor = createEditor()
24
+ editor.config((ctx) => {
25
+ ctx.set(defaultValueCtx, markdown)
26
+ })
27
+
28
+ await editor.create()
29
+
30
+ const schema = editor.ctx.get(schemaCtx)
31
+ const serializer = editor.ctx.get(serializerCtx)
32
+
33
+ // Create a table with an empty header row (content.size === 0)
34
+ const tableHeaderRow = schema.nodes.table_header_row!.create()
35
+ const tableRow = schema.nodes.table_row!.create(null, [
36
+ schema.nodes.table_cell!.create(
37
+ null,
38
+ schema.nodes.paragraph!.create(null, schema.text('cell'))
39
+ ),
40
+ ])
41
+ const table = schema.nodes.table!.create(null, [tableHeaderRow, tableRow])
42
+ const doc = schema.topNodeType.createAndFill(null, [table])!
43
+
44
+ // This should not throw
45
+ expect(() => serializer(doc)).not.toThrow()
46
+ })
47
+
48
+ it('should serialize a normal table correctly', async () => {
49
+ const markdown = `| head |\n| --- |\n| cell |\n`
50
+ const editor = createEditor()
51
+ editor.config((ctx) => {
52
+ ctx.set(defaultValueCtx, markdown)
53
+ })
54
+
55
+ await editor.create()
56
+
57
+ const view = editor.ctx.get(editorViewCtx)
58
+ const serializer = editor.ctx.get(serializerCtx)
59
+
60
+ // Serializing the current doc (which has a normal table) should work
61
+ const result = serializer(view.state.doc)
62
+ expect(result).toContain('head')
63
+ expect(result).toContain('cell')
64
+ })
@@ -0,0 +1,65 @@
1
+ // Mock missing DOM APIs that ProseMirror needs but JSDOM doesn't provide
2
+
3
+ // Mock elementFromPoint
4
+ if (!document.elementFromPoint) {
5
+ document.elementFromPoint = () => null
6
+ }
7
+
8
+ // Mock getClientRects for all elements
9
+ Object.defineProperty(Element.prototype, 'getClientRects', {
10
+ value: function () {
11
+ return {
12
+ length: 0,
13
+ item: () => null,
14
+ [Symbol.iterator]: function* () {},
15
+ }
16
+ },
17
+ })
18
+
19
+ Object.defineProperty(Element.prototype, 'getBoundingClientRect', {
20
+ value: function () {
21
+ return {
22
+ x: 0,
23
+ y: 0,
24
+ width: 0,
25
+ height: 0,
26
+ top: 0,
27
+ right: 0,
28
+ bottom: 0,
29
+ left: 0,
30
+ toJSON: () => ({}),
31
+ }
32
+ },
33
+ })
34
+
35
+ // Mock Range methods
36
+ Object.defineProperty(Range.prototype, 'getClientRects', {
37
+ value: function () {
38
+ return {
39
+ length: 0,
40
+ item: () => null,
41
+ [Symbol.iterator]: function* () {},
42
+ }
43
+ },
44
+ })
45
+
46
+ Object.defineProperty(Range.prototype, 'getBoundingClientRect', {
47
+ value: function () {
48
+ return {
49
+ x: 0,
50
+ y: 0,
51
+ width: 0,
52
+ height: 0,
53
+ top: 0,
54
+ right: 0,
55
+ bottom: 0,
56
+ left: 0,
57
+ toJSON: () => ({}),
58
+ }
59
+ },
60
+ })
61
+
62
+ // Mock scrollIntoView
63
+ Object.defineProperty(Element.prototype, 'scrollIntoView', {
64
+ value: function () {},
65
+ })
@@ -1,7 +1,12 @@
1
1
  import { commandsCtx } from '@milkdown/core'
2
2
  import { paragraphSchema } from '@milkdown/preset-commonmark'
3
3
  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'
5
10
  import { TextSelection } from '@milkdown/prose/state'
6
11
  import { $inputRule, $pasteRule, $useKeymap } from '@milkdown/utils'
7
12
 
@@ -11,7 +16,12 @@ import {
11
16
  goToNextTableCellCommand,
12
17
  goToPrevTableCellCommand,
13
18
  } from './command'
14
- import { tableHeaderSchema, tableSchema } from './schema'
19
+ import {
20
+ tableHeaderRowSchema,
21
+ tableHeaderSchema,
22
+ tableRowSchema,
23
+ tableSchema,
24
+ } from './schema'
15
25
  import { createTable } from './utils'
16
26
 
17
27
  /// A input rule for creating table.
@@ -52,35 +62,53 @@ withMeta(insertTableInputRule, {
52
62
  /// A paste rule for fixing tables without header cells.
53
63
  /// This is a workaround for some editors (e.g. Google Docs) which allow creating tables without header cells,
54
64
  /// which is not supported by Markdown schema.
55
- /// This paste rule will add header cells to the first row if it's missing.
65
+ /// This paste rule will promote the first data row to header, or add empty header cells as a fallback.
56
66
  export const tablePasteRule = $pasteRule((ctx) => ({
57
67
  run: (slice, _view, isPlainText) => {
58
68
  if (isPlainText) {
59
69
  return slice
60
70
  }
61
- let fragment = slice.content
62
71
 
63
- slice.content.forEach((node, _offset, index) => {
64
- if (node?.type !== tableSchema.type(ctx)) {
65
- return
66
- }
72
+ function fixTable(node: ProsemirrorNode): ProsemirrorNode {
67
73
  const rowsCount = node.childCount
68
74
  const colsCount = node.lastChild?.childCount ?? 0
69
75
  if (rowsCount === 0 || colsCount === 0) {
70
- fragment = fragment.replaceChild(
71
- index,
72
- paragraphSchema.type(ctx).create()
73
- )
74
- return
76
+ return paragraphSchema.type(ctx).create()
75
77
  }
76
78
 
77
79
  const headerRow = node.firstChild
78
80
  const needToFixHeaderRow =
79
81
  colsCount > 0 && headerRow && headerRow.childCount === 0
80
82
  if (!needToFixHeaderRow) {
81
- return
83
+ return node
82
84
  }
83
- // Fix for tables with rows but no cells in the first row
85
+
86
+ // If there are 2+ data rows (3+ total: empty header + 2+ data rows),
87
+ // promote the first data row to header
88
+ if (rowsCount >= 3) {
89
+ const firstDataRow = node.child(1)
90
+ const headerCells: ProsemirrorNode[] = []
91
+ for (let i = 0; i < firstDataRow.childCount; i++) {
92
+ const cell = firstDataRow.child(i)
93
+ headerCells.push(
94
+ tableHeaderSchema
95
+ .type(ctx)
96
+ .create(cell.attrs, cell.content, cell.marks)
97
+ )
98
+ }
99
+ const newHeaderRow = headerRow.type.create(headerRow.attrs, headerCells)
100
+
101
+ // Collect remaining data rows (skip promoted row at index 1)
102
+ const remainingRows: ProsemirrorNode[] = []
103
+ for (let i = 2; i < rowsCount; i++) {
104
+ remainingRows.push(node.child(i))
105
+ }
106
+
107
+ return node.type.create(node.attrs, [newHeaderRow, ...remainingRows])
108
+ }
109
+
110
+ // Fallback: only 1 data row, can't promote (would leave 0 data rows).
111
+ // Fill the empty header with blank cells.
84
112
  const headerCells = Array(colsCount)
85
113
  .fill(0)
86
114
  .map(() => tableHeaderSchema.type(ctx).createAndFill()!)
@@ -93,9 +121,97 @@ export const tablePasteRule = $pasteRule((ctx) => ({
93
121
  headerRow.nodeSize,
94
122
  new Slice(Fragment.from(newHeaderRow), 0, 0)
95
123
  )
96
- fragment = fragment.replaceChild(index, newTable)
97
- })
124
+ return newTable
125
+ }
126
+
127
+ // Wrap consecutive orphaned table_row nodes (at the top level of a fragment)
128
+ // into a proper table. This happens when ProseMirror's parseSlice breaks
129
+ // a table apart (e.g. when pasting multiple tables from Google Docs).
130
+ function wrapOrphanedRows(fragment: FragmentType): FragmentType {
131
+ const rowType = tableRowSchema.type(ctx)
132
+ const nodes: ProsemirrorNode[] = []
133
+ let pendingRows: ProsemirrorNode[] = []
134
+ let hasOrphans = false
135
+
136
+ function flushPendingRows() {
137
+ if (pendingRows.length === 0) return
138
+
139
+ // Create an empty table_header_row, then fixTable will promote the first data row
140
+ const emptyHeaderRow = tableHeaderRowSchema.type(ctx).createAndFill()!
141
+ const table = tableSchema
142
+ .type(ctx)
143
+ .create(null, [emptyHeaderRow, ...pendingRows])
144
+ nodes.push(fixTable(table))
145
+ pendingRows = []
146
+ }
147
+
148
+ fragment.forEach((node) => {
149
+ if (node.type === rowType) {
150
+ hasOrphans = true
151
+ pendingRows.push(node)
152
+ } else {
153
+ flushPendingRows()
154
+ nodes.push(node)
155
+ }
156
+ })
157
+ flushPendingRows()
158
+
159
+ return hasOrphans ? Fragment.from(nodes) : fragment
160
+ }
161
+
162
+ function fixFragment(fragment: FragmentType): FragmentType {
163
+ // First, wrap any orphaned table_row nodes into tables
164
+ let result = wrapOrphanedRows(fragment)
165
+
166
+ // Then fix existing tables and recurse into children
167
+ let changed = result !== fragment
168
+ const fixed: ProsemirrorNode[] = []
169
+ result.forEach((node) => {
170
+ if (node.type === tableSchema.type(ctx)) {
171
+ const fixedNode = fixTable(node)
172
+ if (fixedNode !== node) changed = true
173
+ fixed.push(fixedNode)
174
+ } else if (node.childCount > 0) {
175
+ const fixedContent = fixFragment(node.content)
176
+ if (fixedContent !== node.content) {
177
+ changed = true
178
+ fixed.push(node.copy(fixedContent))
179
+ } else {
180
+ fixed.push(node)
181
+ }
182
+ } else {
183
+ fixed.push(node)
184
+ }
185
+ })
186
+ return changed ? Fragment.from(fixed) : fragment
187
+ }
188
+
189
+ // Remove empty paragraphs that directly precede a table
190
+ // (artifacts of broken table parsing from Google Docs)
191
+ function cleanEmptyParagraphs(fragment: FragmentType): FragmentType {
192
+ const nodes: ProsemirrorNode[] = []
193
+ const allNodes: ProsemirrorNode[] = []
194
+ fragment.forEach((node) => allNodes.push(node))
195
+
196
+ for (let i = 0; i < allNodes.length; i++) {
197
+ const node = allNodes[i]!
198
+ const next = allNodes[i + 1]
199
+ if (
200
+ node.type === paragraphSchema.type(ctx) &&
201
+ node.content.size === 0 &&
202
+ next &&
203
+ next.type === tableSchema.type(ctx)
204
+ ) {
205
+ continue // skip empty paragraph before table
206
+ }
207
+ nodes.push(node)
208
+ }
209
+
210
+ return nodes.length < allNodes.length ? Fragment.from(nodes) : fragment
211
+ }
98
212
 
213
+ let fragment = fixFragment(slice.content)
214
+ fragment = cleanEmptyParagraphs(fragment)
99
215
  return new Slice(Fragment.from(fragment), slice.openStart, slice.openEnd)
100
216
  },
101
217
  }))
@@ -104,6 +104,11 @@ export const tableHeaderRowSchema = $nodeSchema('table_header_row', () => ({
104
104
  toMarkdown: {
105
105
  match: (node) => node.type.name === 'table_header_row',
106
106
  runner: (state, node) => {
107
+ // if the row is empty, we don't need to create a table row
108
+ // prevent remark from crashing
109
+ if (node.content.size === 0) {
110
+ return
111
+ }
107
112
  state.openNode('tableRow', undefined, { isHeader: true })
108
113
  state.next(node.content)
109
114
  state.closeNode()