@tiptap/core 3.19.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.
@@ -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(/&amp;/g, '&')
360
+ },
361
+ }),
362
+ ],
363
+ })
364
+
365
+ const result = editor.view.props.transformPastedHTML?.('<p>&amp;test&amp;</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
- globalAttribute.types.forEach(type => {
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
  */