@tiptap/core 3.18.0 → 3.20.0
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/dist/index.cjs +56 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +38 -2
- package/dist/index.d.ts +38 -2
- package/dist/index.js +56 -2
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/Editor.ts +8 -3
- package/src/Extendable.ts +24 -0
- package/src/ExtensionManager.ts +42 -1
- package/src/__tests__/transformPastedHTML.test.ts +575 -0
- package/src/helpers/getAttributesFromExtensions.ts +20 -1
- package/src/helpers/isMarkActive.ts +5 -0
- package/src/types.ts +11 -1
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
import { Editor, Extension } from '@tiptap/core'
|
|
2
|
+
import Document from '@tiptap/extension-document'
|
|
3
|
+
import Paragraph from '@tiptap/extension-paragraph'
|
|
4
|
+
import Text from '@tiptap/extension-text'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
|
|
7
|
+
describe('transformPastedHTML', () => {
|
|
8
|
+
describe('priority ordering', () => {
|
|
9
|
+
it('should execute transforms in priority order (higher priority first)', () => {
|
|
10
|
+
const executionOrder: number[] = []
|
|
11
|
+
|
|
12
|
+
const editor = new Editor({
|
|
13
|
+
extensions: [
|
|
14
|
+
Document,
|
|
15
|
+
Paragraph,
|
|
16
|
+
Text,
|
|
17
|
+
Extension.create({
|
|
18
|
+
name: 'low-priority',
|
|
19
|
+
priority: 50,
|
|
20
|
+
transformPastedHTML(html) {
|
|
21
|
+
executionOrder.push(3)
|
|
22
|
+
return html
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
Extension.create({
|
|
26
|
+
name: 'high-priority',
|
|
27
|
+
priority: 200,
|
|
28
|
+
transformPastedHTML(html) {
|
|
29
|
+
executionOrder.push(1)
|
|
30
|
+
return html
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
Extension.create({
|
|
34
|
+
name: 'medium-priority',
|
|
35
|
+
priority: 100,
|
|
36
|
+
transformPastedHTML(html) {
|
|
37
|
+
executionOrder.push(2)
|
|
38
|
+
return html
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
],
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
editor.view.props.transformPastedHTML?.('<p>test</p>')
|
|
45
|
+
|
|
46
|
+
expect(executionOrder).toEqual([1, 2, 3])
|
|
47
|
+
|
|
48
|
+
editor.destroy()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should execute transforms in default priority order when priorities are equal', () => {
|
|
52
|
+
const executionOrder: string[] = []
|
|
53
|
+
|
|
54
|
+
const editor = new Editor({
|
|
55
|
+
extensions: [
|
|
56
|
+
Document,
|
|
57
|
+
Paragraph,
|
|
58
|
+
Text,
|
|
59
|
+
Extension.create({
|
|
60
|
+
name: 'first',
|
|
61
|
+
transformPastedHTML(html) {
|
|
62
|
+
executionOrder.push('first')
|
|
63
|
+
return html
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
Extension.create({
|
|
67
|
+
name: 'second',
|
|
68
|
+
transformPastedHTML(html) {
|
|
69
|
+
executionOrder.push('second')
|
|
70
|
+
return html
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
],
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
editor.view.props.transformPastedHTML?.('<p>test</p>')
|
|
77
|
+
|
|
78
|
+
expect(executionOrder).toEqual(['first', 'second'])
|
|
79
|
+
|
|
80
|
+
editor.destroy()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('transform chaining', () => {
|
|
85
|
+
it('should chain transforms correctly', () => {
|
|
86
|
+
const editor = new Editor({
|
|
87
|
+
extensions: [
|
|
88
|
+
Document,
|
|
89
|
+
Paragraph,
|
|
90
|
+
Text,
|
|
91
|
+
Extension.create({
|
|
92
|
+
name: 'first-transform',
|
|
93
|
+
priority: 100,
|
|
94
|
+
transformPastedHTML(html) {
|
|
95
|
+
return html.replace(/foo/g, 'bar')
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
Extension.create({
|
|
99
|
+
name: 'second-transform',
|
|
100
|
+
priority: 90,
|
|
101
|
+
transformPastedHTML(html) {
|
|
102
|
+
return html.replace(/bar/g, 'baz')
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
],
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const result = editor.view.props.transformPastedHTML?.('<p>foo</p>')
|
|
109
|
+
|
|
110
|
+
expect(result).toBe('<p>baz</p>')
|
|
111
|
+
|
|
112
|
+
editor.destroy()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('should pass transformed HTML through entire chain', () => {
|
|
116
|
+
const editor = new Editor({
|
|
117
|
+
extensions: [
|
|
118
|
+
Document,
|
|
119
|
+
Paragraph,
|
|
120
|
+
Text,
|
|
121
|
+
Extension.create({
|
|
122
|
+
name: 'add-prefix',
|
|
123
|
+
priority: 100,
|
|
124
|
+
transformPastedHTML(html) {
|
|
125
|
+
return `PREFIX-${html}`
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
Extension.create({
|
|
129
|
+
name: 'add-suffix',
|
|
130
|
+
priority: 90,
|
|
131
|
+
transformPastedHTML(html) {
|
|
132
|
+
return `${html}-SUFFIX`
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
Extension.create({
|
|
136
|
+
name: 'add-wrapper',
|
|
137
|
+
priority: 80,
|
|
138
|
+
transformPastedHTML(html) {
|
|
139
|
+
return `[${html}]`
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
],
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const result = editor.view.props.transformPastedHTML?.('TEST')
|
|
146
|
+
|
|
147
|
+
expect(result).toBe('[PREFIX-TEST-SUFFIX]')
|
|
148
|
+
|
|
149
|
+
editor.destroy()
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('baseTransform integration', () => {
|
|
154
|
+
it('should run baseTransform before extension transforms', () => {
|
|
155
|
+
const editor = new Editor({
|
|
156
|
+
editorProps: {
|
|
157
|
+
transformPastedHTML(html) {
|
|
158
|
+
return html.replace(/original/g, 'base')
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
extensions: [
|
|
162
|
+
Document,
|
|
163
|
+
Paragraph,
|
|
164
|
+
Text,
|
|
165
|
+
Extension.create({
|
|
166
|
+
name: 'extension-transform',
|
|
167
|
+
transformPastedHTML(html) {
|
|
168
|
+
return html.replace(/base/g, 'final')
|
|
169
|
+
},
|
|
170
|
+
}),
|
|
171
|
+
],
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const result = editor.view.props.transformPastedHTML?.('<p>original</p>')
|
|
175
|
+
|
|
176
|
+
expect(result).toBe('<p>final</p>')
|
|
177
|
+
|
|
178
|
+
editor.destroy()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('should work when baseTransform is undefined', () => {
|
|
182
|
+
const editor = new Editor({
|
|
183
|
+
extensions: [
|
|
184
|
+
Document,
|
|
185
|
+
Paragraph,
|
|
186
|
+
Text,
|
|
187
|
+
Extension.create({
|
|
188
|
+
name: 'extension-transform',
|
|
189
|
+
transformPastedHTML(html) {
|
|
190
|
+
return html.replace(/test/g, 'success')
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
],
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
const result = editor.view.props.transformPastedHTML?.('<p>test</p>')
|
|
197
|
+
|
|
198
|
+
expect(result).toBe('<p>success</p>')
|
|
199
|
+
|
|
200
|
+
editor.destroy()
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe('extensions without transforms', () => {
|
|
205
|
+
it('should skip extensions without transformPastedHTML', () => {
|
|
206
|
+
const editor = new Editor({
|
|
207
|
+
extensions: [
|
|
208
|
+
Document,
|
|
209
|
+
Paragraph,
|
|
210
|
+
Text,
|
|
211
|
+
Extension.create({
|
|
212
|
+
name: 'no-transform',
|
|
213
|
+
// No transformPastedHTML defined
|
|
214
|
+
}),
|
|
215
|
+
Extension.create({
|
|
216
|
+
name: 'with-transform',
|
|
217
|
+
transformPastedHTML(html) {
|
|
218
|
+
return html.replace(/test/g, 'success')
|
|
219
|
+
},
|
|
220
|
+
}),
|
|
221
|
+
Extension.create({
|
|
222
|
+
name: 'another-no-transform',
|
|
223
|
+
// No transformPastedHTML defined
|
|
224
|
+
}),
|
|
225
|
+
],
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const result = editor.view.props.transformPastedHTML?.('<p>test</p>')
|
|
229
|
+
|
|
230
|
+
expect(result).toBe('<p>success</p>')
|
|
231
|
+
|
|
232
|
+
editor.destroy()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('should return original HTML when no transforms are defined', () => {
|
|
236
|
+
const editor = new Editor({
|
|
237
|
+
extensions: [
|
|
238
|
+
Document,
|
|
239
|
+
Paragraph,
|
|
240
|
+
Text,
|
|
241
|
+
Extension.create({
|
|
242
|
+
name: 'extension-1',
|
|
243
|
+
}),
|
|
244
|
+
Extension.create({
|
|
245
|
+
name: 'extension-2',
|
|
246
|
+
}),
|
|
247
|
+
],
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
const result = editor.view.props.transformPastedHTML?.('<p>unchanged</p>')
|
|
251
|
+
|
|
252
|
+
expect(result).toBe('<p>unchanged</p>')
|
|
253
|
+
|
|
254
|
+
editor.destroy()
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
describe('extension context', () => {
|
|
259
|
+
it('should provide correct context to transformPastedHTML', () => {
|
|
260
|
+
let capturedContext: any = null
|
|
261
|
+
|
|
262
|
+
const editor = new Editor({
|
|
263
|
+
extensions: [
|
|
264
|
+
Document,
|
|
265
|
+
Paragraph,
|
|
266
|
+
Text,
|
|
267
|
+
Extension.create({
|
|
268
|
+
name: 'test-extension',
|
|
269
|
+
addOptions() {
|
|
270
|
+
return {
|
|
271
|
+
customOption: 'value',
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
addStorage() {
|
|
275
|
+
return {
|
|
276
|
+
customStorage: 'stored',
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
transformPastedHTML(html) {
|
|
280
|
+
capturedContext = {
|
|
281
|
+
name: this.name,
|
|
282
|
+
options: this.options,
|
|
283
|
+
storage: this.storage,
|
|
284
|
+
editor: this.editor,
|
|
285
|
+
}
|
|
286
|
+
return html
|
|
287
|
+
},
|
|
288
|
+
}),
|
|
289
|
+
],
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
editor.view.props.transformPastedHTML?.('<p>test</p>')
|
|
293
|
+
|
|
294
|
+
expect(capturedContext).toBeDefined()
|
|
295
|
+
expect(capturedContext.name).toBe('test-extension')
|
|
296
|
+
expect(capturedContext.options).toMatchObject({ customOption: 'value' })
|
|
297
|
+
expect(capturedContext.storage).toMatchObject({ customStorage: 'stored' })
|
|
298
|
+
expect(capturedContext.editor).toBe(editor)
|
|
299
|
+
|
|
300
|
+
editor.destroy()
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('should allow accessing editor state in transformPastedHTML', () => {
|
|
304
|
+
const editor = new Editor({
|
|
305
|
+
extensions: [
|
|
306
|
+
Document,
|
|
307
|
+
Paragraph,
|
|
308
|
+
Text,
|
|
309
|
+
Extension.create({
|
|
310
|
+
name: 'state-aware',
|
|
311
|
+
transformPastedHTML(html) {
|
|
312
|
+
const isEmpty = this.editor.isEmpty
|
|
313
|
+
return isEmpty ? `${html}<!-- empty -->` : html
|
|
314
|
+
},
|
|
315
|
+
}),
|
|
316
|
+
],
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
const result = editor.view.props.transformPastedHTML?.('<p>test</p>')
|
|
320
|
+
|
|
321
|
+
expect(result).toContain('<!-- empty -->')
|
|
322
|
+
|
|
323
|
+
editor.destroy()
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
describe('edge cases', () => {
|
|
328
|
+
it('should handle empty HTML string', () => {
|
|
329
|
+
const editor = new Editor({
|
|
330
|
+
extensions: [
|
|
331
|
+
Document,
|
|
332
|
+
Paragraph,
|
|
333
|
+
Text,
|
|
334
|
+
Extension.create({
|
|
335
|
+
name: 'transform',
|
|
336
|
+
transformPastedHTML(html) {
|
|
337
|
+
return html || '<p>default</p>'
|
|
338
|
+
},
|
|
339
|
+
}),
|
|
340
|
+
],
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
const result = editor.view.props.transformPastedHTML?.('')
|
|
344
|
+
|
|
345
|
+
expect(result).toBe('<p>default</p>')
|
|
346
|
+
|
|
347
|
+
editor.destroy()
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('should handle HTML with special characters', () => {
|
|
351
|
+
const editor = new Editor({
|
|
352
|
+
extensions: [
|
|
353
|
+
Document,
|
|
354
|
+
Paragraph,
|
|
355
|
+
Text,
|
|
356
|
+
Extension.create({
|
|
357
|
+
name: 'preserve-special',
|
|
358
|
+
transformPastedHTML(html) {
|
|
359
|
+
return html.replace(/&/g, '&')
|
|
360
|
+
},
|
|
361
|
+
}),
|
|
362
|
+
],
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
const result = editor.view.props.transformPastedHTML?.('<p>&test&</p>')
|
|
366
|
+
|
|
367
|
+
expect(result).toBe('<p>&test&</p>')
|
|
368
|
+
|
|
369
|
+
editor.destroy()
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('should handle very long HTML strings', () => {
|
|
373
|
+
const editor = new Editor({
|
|
374
|
+
extensions: [
|
|
375
|
+
Document,
|
|
376
|
+
Paragraph,
|
|
377
|
+
Text,
|
|
378
|
+
Extension.create({
|
|
379
|
+
name: 'transform',
|
|
380
|
+
transformPastedHTML(html) {
|
|
381
|
+
return html.replace(/test/g, 'success')
|
|
382
|
+
},
|
|
383
|
+
}),
|
|
384
|
+
],
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
const longHtml = `<p>${'test '.repeat(10000)}</p>`
|
|
388
|
+
const result = editor.view.props.transformPastedHTML?.(longHtml)
|
|
389
|
+
|
|
390
|
+
expect(result).toContain('success')
|
|
391
|
+
expect(result).not.toContain('test')
|
|
392
|
+
|
|
393
|
+
editor.destroy()
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('should handle malformed HTML gracefully', () => {
|
|
397
|
+
const editor = new Editor({
|
|
398
|
+
extensions: [
|
|
399
|
+
Document,
|
|
400
|
+
Paragraph,
|
|
401
|
+
Text,
|
|
402
|
+
Extension.create({
|
|
403
|
+
name: 'transform',
|
|
404
|
+
transformPastedHTML(html) {
|
|
405
|
+
return html.replace(/test/g, 'success')
|
|
406
|
+
},
|
|
407
|
+
}),
|
|
408
|
+
],
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
const malformedHtml = '<p>test</span>'
|
|
412
|
+
const result = editor.view.props.transformPastedHTML?.(malformedHtml)
|
|
413
|
+
|
|
414
|
+
expect(result).toBe('<p>success</span>')
|
|
415
|
+
|
|
416
|
+
editor.destroy()
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
describe('view parameter', () => {
|
|
421
|
+
it('should pass view parameter to baseTransform', () => {
|
|
422
|
+
let viewReceived: any = null
|
|
423
|
+
|
|
424
|
+
const editor = new Editor({
|
|
425
|
+
editorProps: {
|
|
426
|
+
transformPastedHTML(html, view) {
|
|
427
|
+
viewReceived = view
|
|
428
|
+
return html
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
extensions: [Document, Paragraph, Text],
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
editor.view.props.transformPastedHTML?.('<p>test</p>', editor.view)
|
|
435
|
+
|
|
436
|
+
expect(viewReceived).toBe(editor.view)
|
|
437
|
+
|
|
438
|
+
editor.destroy()
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('should work when view parameter is undefined', () => {
|
|
442
|
+
const editor = new Editor({
|
|
443
|
+
editorProps: {
|
|
444
|
+
transformPastedHTML(html, view) {
|
|
445
|
+
return view ? html : `${html}<!-- no view -->`
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
extensions: [Document, Paragraph, Text],
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
const result = editor.view.props.transformPastedHTML?.('<p>test</p>')
|
|
452
|
+
|
|
453
|
+
expect(result).toContain('<!-- no view -->')
|
|
454
|
+
|
|
455
|
+
editor.destroy()
|
|
456
|
+
})
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
describe('real-world scenarios', () => {
|
|
460
|
+
it('should remove inline styles and dangerous attributes', () => {
|
|
461
|
+
const editor = new Editor({
|
|
462
|
+
extensions: [
|
|
463
|
+
Document,
|
|
464
|
+
Paragraph,
|
|
465
|
+
Text,
|
|
466
|
+
Extension.create({
|
|
467
|
+
name: 'security',
|
|
468
|
+
priority: 100,
|
|
469
|
+
transformPastedHTML(html) {
|
|
470
|
+
return html.replace(/\s+style="[^"]*"/gi, '').replace(/\s+on\w+="[^"]*"/gi, '')
|
|
471
|
+
},
|
|
472
|
+
}),
|
|
473
|
+
],
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
const result = editor.view.props.transformPastedHTML?.('<p style="color: red;" onclick="alert(\'xss\')">test</p>')
|
|
477
|
+
|
|
478
|
+
expect(result).toBe('<p>test</p>')
|
|
479
|
+
|
|
480
|
+
editor.destroy()
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it('should normalize whitespace from word processors', () => {
|
|
484
|
+
const editor = new Editor({
|
|
485
|
+
extensions: [
|
|
486
|
+
Document,
|
|
487
|
+
Paragraph,
|
|
488
|
+
Text,
|
|
489
|
+
Extension.create({
|
|
490
|
+
name: 'normalize-whitespace',
|
|
491
|
+
transformPastedHTML(html) {
|
|
492
|
+
return html
|
|
493
|
+
.replace(/\t/g, ' ')
|
|
494
|
+
.replace(/\u00a0/g, ' ')
|
|
495
|
+
.replace(/\s+/g, ' ')
|
|
496
|
+
},
|
|
497
|
+
}),
|
|
498
|
+
],
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
const result = editor.view.props.transformPastedHTML?.('<p>test\t\u00a0 multiple spaces</p>')
|
|
502
|
+
|
|
503
|
+
expect(result).toBe('<p>test multiple spaces</p>')
|
|
504
|
+
|
|
505
|
+
editor.destroy()
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
it('should chain multiple practical transforms', () => {
|
|
509
|
+
const editor = new Editor({
|
|
510
|
+
extensions: [
|
|
511
|
+
Document,
|
|
512
|
+
Paragraph,
|
|
513
|
+
Text,
|
|
514
|
+
Extension.create({
|
|
515
|
+
name: 'remove-styles',
|
|
516
|
+
priority: 100,
|
|
517
|
+
transformPastedHTML(html) {
|
|
518
|
+
return html.replace(/\s+style="[^"]*"/gi, '')
|
|
519
|
+
},
|
|
520
|
+
}),
|
|
521
|
+
Extension.create({
|
|
522
|
+
name: 'normalize-tags',
|
|
523
|
+
priority: 90,
|
|
524
|
+
transformPastedHTML(html) {
|
|
525
|
+
return html.replace(/<b>/g, '<strong>').replace(/<\/b>/g, '</strong>')
|
|
526
|
+
},
|
|
527
|
+
}),
|
|
528
|
+
Extension.create({
|
|
529
|
+
name: 'add-classes',
|
|
530
|
+
priority: 80,
|
|
531
|
+
transformPastedHTML(html) {
|
|
532
|
+
return html.replace(/<p>/g, '<p class="editor-paragraph">')
|
|
533
|
+
},
|
|
534
|
+
}),
|
|
535
|
+
],
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
const result = editor.view.props.transformPastedHTML?.('<p style="color: red;"><b>test</b></p>')
|
|
539
|
+
|
|
540
|
+
expect(result).toBe('<p class="editor-paragraph"><strong>test</strong></p>')
|
|
541
|
+
|
|
542
|
+
editor.destroy()
|
|
543
|
+
})
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
describe('performance', () => {
|
|
547
|
+
it('should handle many extensions efficiently', () => {
|
|
548
|
+
const extensions = [Document, Paragraph, Text]
|
|
549
|
+
|
|
550
|
+
// Add 50 extensions with transforms
|
|
551
|
+
for (let i = 0; i < 50; i += 1) {
|
|
552
|
+
extensions.push(
|
|
553
|
+
Extension.create({
|
|
554
|
+
name: `extension-${i}`,
|
|
555
|
+
priority: 1000 - i,
|
|
556
|
+
transformPastedHTML(html) {
|
|
557
|
+
return html // Pass through
|
|
558
|
+
},
|
|
559
|
+
}),
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const editor = new Editor({ extensions })
|
|
564
|
+
|
|
565
|
+
const start = Date.now()
|
|
566
|
+
const result = editor.view.props.transformPastedHTML?.('<p>test</p>')
|
|
567
|
+
const duration = Date.now() - start
|
|
568
|
+
|
|
569
|
+
expect(result).toBe('<p>test</p>')
|
|
570
|
+
expect(duration).toBeLessThan(100) // Should complete quickly
|
|
571
|
+
|
|
572
|
+
editor.destroy()
|
|
573
|
+
})
|
|
574
|
+
})
|
|
575
|
+
})
|
|
@@ -21,6 +21,11 @@ export function getAttributesFromExtensions(extensions: Extensions): ExtensionAt
|
|
|
21
21
|
isRequired: false,
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Precompute lists of extension types for global attribute resolution
|
|
25
|
+
const nodeExtensionTypes = nodeExtensions.filter(ext => ext.name !== 'text').map(ext => ext.name)
|
|
26
|
+
const markExtensionTypes = markExtensions.map(ext => ext.name)
|
|
27
|
+
const allExtensionTypes = [...nodeExtensionTypes, ...markExtensionTypes]
|
|
28
|
+
|
|
24
29
|
extensions.forEach(extension => {
|
|
25
30
|
const context = {
|
|
26
31
|
name: extension.name,
|
|
@@ -42,7 +47,21 @@ export function getAttributesFromExtensions(extensions: Extensions): ExtensionAt
|
|
|
42
47
|
const globalAttributes = addGlobalAttributes()
|
|
43
48
|
|
|
44
49
|
globalAttributes.forEach(globalAttribute => {
|
|
45
|
-
|
|
50
|
+
// Resolve the types based on the string shorthand or explicit array
|
|
51
|
+
let resolvedTypes: string[]
|
|
52
|
+
if (Array.isArray(globalAttribute.types)) {
|
|
53
|
+
resolvedTypes = globalAttribute.types
|
|
54
|
+
} else if (globalAttribute.types === '*') {
|
|
55
|
+
resolvedTypes = allExtensionTypes
|
|
56
|
+
} else if (globalAttribute.types === 'nodes') {
|
|
57
|
+
resolvedTypes = nodeExtensionTypes
|
|
58
|
+
} else if (globalAttribute.types === 'marks') {
|
|
59
|
+
resolvedTypes = markExtensionTypes
|
|
60
|
+
} else {
|
|
61
|
+
resolvedTypes = []
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
resolvedTypes.forEach(type => {
|
|
46
65
|
Object.entries(globalAttribute.attributes).forEach(([name, attribute]) => {
|
|
47
66
|
extensionAttributes.push({
|
|
48
67
|
type,
|
|
@@ -33,6 +33,11 @@ export function isMarkActive(
|
|
|
33
33
|
const to = $to.pos
|
|
34
34
|
|
|
35
35
|
state.doc.nodesBetween(from, to, (node, pos) => {
|
|
36
|
+
// ignore selected text inside nodes whose schema disallows this mark type
|
|
37
|
+
if (type && node.inlineContent && !node.type.allowsMarkType(type)) {
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
|
|
36
41
|
if (!node.isText && !node.marks.length) {
|
|
37
42
|
return
|
|
38
43
|
}
|
package/src/types.ts
CHANGED
|
@@ -652,8 +652,18 @@ export type ExtensionAttribute = {
|
|
|
652
652
|
export type GlobalAttributes = {
|
|
653
653
|
/**
|
|
654
654
|
* The node & mark types this attribute should be applied to.
|
|
655
|
+
* Can be a specific array of type names, or a shorthand string:
|
|
656
|
+
* - `'*'` applies to all nodes (excluding text) and all marks
|
|
657
|
+
* - `'nodes'` applies to all nodes (excluding the built-in text node)
|
|
658
|
+
* - `'marks'` applies to all marks
|
|
659
|
+
* - `string[]` applies to specific node/mark types by name
|
|
660
|
+
* @example
|
|
661
|
+
* types: '*' // All nodes and marks
|
|
662
|
+
* types: 'nodes' // All nodes
|
|
663
|
+
* types: 'marks' // All marks
|
|
664
|
+
* types: ['heading', 'paragraph'] // Specific types
|
|
655
665
|
*/
|
|
656
|
-
types: string[]
|
|
666
|
+
types: string[] | 'nodes' | 'marks' | '*'
|
|
657
667
|
/**
|
|
658
668
|
* The attributes to add to the node or mark types.
|
|
659
669
|
*/
|