@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.
- package/lib/__test__/table-header-row.spec.d.ts +2 -0
- package/lib/__test__/table-header-row.spec.d.ts.map +1 -0
- package/lib/__test__/vitest.setup.d.ts +1 -0
- package/lib/__test__/vitest.setup.d.ts.map +1 -0
- package/lib/index.js +940 -1015
- package/lib/index.js.map +1 -1
- package/lib/node/table/input.d.ts.map +1 -1
- package/lib/node/table/schema.d.ts.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/src/__test__/table-header-row.spec.ts +64 -0
- package/src/__test__/vitest.setup.ts +65 -0
- package/src/node/table/input.ts +133 -17
- package/src/node/table/schema.ts +5 -0
|
@@ -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
|
+
})
|
package/src/node/table/input.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}))
|
package/src/node/table/schema.ts
CHANGED
|
@@ -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()
|