@tuturuuu/ui 0.28.0 → 0.29.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.
@@ -1,7 +1,42 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { Editor } from '@tiptap/core';
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import {
4
+ serializeClipboardPlainText,
5
+ serializeClipboardText,
6
+ } from '../clipboard-serialization';
7
+ import { getEditorExtensions } from '../extensions';
2
8
  import { __markdownPastePrivate } from '../markdown-paste-extension';
3
9
 
4
- const { markdownToHtml } = __markdownPastePrivate;
10
+ const {
11
+ markdownToHtml,
12
+ looksLikeMarkdown,
13
+ normalizePastedPlainText,
14
+ shouldConvertPastedText,
15
+ } = __markdownPastePrivate;
16
+
17
+ const editors: Editor[] = [];
18
+
19
+ function createEditor(content: Record<string, unknown>) {
20
+ const editor = new Editor({ content, extensions: getEditorExtensions() });
21
+ editors.push(editor);
22
+ return editor;
23
+ }
24
+
25
+ function copyDocument(editor: Editor) {
26
+ return serializeClipboardText(
27
+ editor.state.doc.slice(0, editor.state.doc.content.size)
28
+ );
29
+ }
30
+
31
+ function copyDocumentAsPlainText(editor: Editor) {
32
+ return serializeClipboardPlainText(
33
+ editor.state.doc.slice(0, editor.state.doc.content.size)
34
+ );
35
+ }
36
+
37
+ afterEach(() => {
38
+ for (const editor of editors.splice(0)) editor.destroy();
39
+ });
5
40
 
6
41
  describe('markdownToHtml', () => {
7
42
  it('should convert headings', () => {
@@ -47,6 +82,65 @@ describe('markdownToHtml', () => {
47
82
  expect(html).toContain('<li><p>item 2</p></li>');
48
83
  });
49
84
 
85
+ it('normalizes copied visual bullets into structured list markers', () => {
86
+ const text = normalizePastedPlainText(
87
+ 'Next steps\r\n• Draft the interview plan\r\n◦ Share it with the team'
88
+ );
89
+
90
+ expect(text).toBe(
91
+ 'Next steps\n- Draft the interview plan\n- Share it with the team'
92
+ );
93
+ expect(looksLikeMarkdown(text)).toBe(true);
94
+ expect(markdownToHtml(text)).toContain(
95
+ '<ul><li><p>Draft the interview plan</p></li><li><p>Share it with the team</p></li></ul>'
96
+ );
97
+ });
98
+
99
+ it('normalizes copied visual checkboxes into task list markers', () => {
100
+ const text = normalizePastedPlainText('☐ Plan interviews\n☑ Invite team');
101
+
102
+ expect(text).toBe('- [ ] Plan interviews\n- [x] Invite team');
103
+ expect(markdownToHtml(text)).toContain('data-type="taskList"');
104
+ });
105
+
106
+ it('collapses pathological blank-line runs without removing section spacing', () => {
107
+ const text = normalizePastedPlainText(
108
+ 'First section\r\n\r\n\r\n\r\nSecond section\n\n\n- Item'
109
+ );
110
+
111
+ expect(text).toBe('First section\n\nSecond section\n\n- Item');
112
+ });
113
+
114
+ it('does not collapse intentional blank lines inside fenced code', () => {
115
+ const text = normalizePastedPlainText(
116
+ 'Before\n\n\n```ts\nfirst()\n\n\nsecond()\n```\n\n\nAfter'
117
+ );
118
+
119
+ expect(text).toBe('Before\n\n```ts\nfirst()\n\n\nsecond()\n```\n\nAfter');
120
+ });
121
+
122
+ it('keeps semantic rich HTML for long structured task descriptions', () => {
123
+ const text = normalizePastedPlainText(
124
+ 'KEEPING FROM THE NEW VERSION:\n• The new About Me UI editor: I like the compactness of it more than the older version.'
125
+ );
126
+ const html = [
127
+ '<h1>KEEPING FROM THE NEW VERSION:</h1>',
128
+ '<ul><li><p><strong>The new About Me UI editor:</strong> ',
129
+ 'I like the compactness of it more than the older version.</p></li></ul>',
130
+ ].join('');
131
+
132
+ expect(looksLikeMarkdown(text)).toBe(true);
133
+ expect(shouldConvertPastedText({ html, text })).toBe(false);
134
+ });
135
+
136
+ it('converts Markdown when clipboard HTML is only a visual wrapper', () => {
137
+ const text = '# Heading\n- Item';
138
+ const html = '<div># Heading<br>- Item</div>';
139
+
140
+ expect(shouldConvertPastedText({ html, text })).toBe(true);
141
+ expect(shouldConvertPastedText({ html: '', text })).toBe(true);
142
+ });
143
+
50
144
  it('should convert ordered lists', () => {
51
145
  const md = '1. first\n2. second';
52
146
  const html = markdownToHtml(md);
@@ -63,6 +157,16 @@ describe('markdownToHtml', () => {
63
157
  expect(html).toContain('data-checked="true"');
64
158
  });
65
159
 
160
+ it('keeps adjacent regular and task lists as separate structures', () => {
161
+ const html = markdownToHtml(
162
+ '- Regular item\n\n- [ ] Pending task\n- [x] Finished task'
163
+ );
164
+
165
+ expect(html).toContain('<ul><li><p>Regular item</p></li></ul>');
166
+ expect(html).toContain('<ul data-type="taskList">');
167
+ expect(html).not.toContain('data-checked="null"');
168
+ });
169
+
66
170
  it('should convert nested unordered lists', () => {
67
171
  const md = '- Item A\n - Nested A1\n - Nested A2\n- Item B';
68
172
  const html = markdownToHtml(md);
@@ -86,6 +190,12 @@ describe('markdownToHtml', () => {
86
190
  );
87
191
  });
88
192
 
193
+ it('preserves a copied ordered-list start number', () => {
194
+ const html = markdownToHtml('3. Third\n4. Fourth');
195
+
196
+ expect(html).toContain('<ol start="3">');
197
+ });
198
+
89
199
  it('should convert deeply nested lists', () => {
90
200
  const md = '- A\n - B\n - C\n - D';
91
201
  const html = markdownToHtml(md);
@@ -210,6 +320,12 @@ describe('markdownToHtml', () => {
210
320
  expect(html).toContain('<p>Hello world</p>');
211
321
  });
212
322
 
323
+ it('preserves intentional line breaks inside pasted paragraphs', () => {
324
+ const html = markdownToHtml('Line one\nLine two');
325
+
326
+ expect(html).toContain('<p>Line one<br>Line two</p>');
327
+ });
328
+
213
329
  it('should escape HTML in plain text', () => {
214
330
  const md = '<script>alert(1)</script>';
215
331
  const html = markdownToHtml(md);
@@ -264,3 +380,304 @@ describe('markdownToHtml', () => {
264
380
  expect(html).not.toContain('<div>');
265
381
  });
266
382
  });
383
+
384
+ describe('task-description clipboard serialization', () => {
385
+ it('removes duplicate empty blocks while retaining one section gap', () => {
386
+ const editor = createEditor({
387
+ type: 'doc',
388
+ content: [
389
+ {
390
+ type: 'heading',
391
+ attrs: { level: 1 },
392
+ content: [{ type: 'text', text: 'KEEPING FROM THE NEW VERSION:' }],
393
+ },
394
+ { type: 'paragraph' },
395
+ { type: 'paragraph' },
396
+ { type: 'paragraph' },
397
+ {
398
+ type: 'paragraph',
399
+ content: [{ type: 'text', text: 'Intro' }],
400
+ },
401
+ { type: 'paragraph' },
402
+ { type: 'paragraph' },
403
+ {
404
+ type: 'heading',
405
+ attrs: { level: 2 },
406
+ content: [{ type: 'text', text: 'Next section' }],
407
+ },
408
+ ],
409
+ });
410
+
411
+ expect(copyDocument(editor)).toBe(
412
+ '# KEEPING FROM THE NEW VERSION:\n\nIntro\n\n## Next section'
413
+ );
414
+ });
415
+
416
+ it('retains unordered, nested ordered, and task-list markers', () => {
417
+ const editor = createEditor({
418
+ type: 'doc',
419
+ content: [
420
+ {
421
+ type: 'bulletList',
422
+ content: [
423
+ {
424
+ type: 'listItem',
425
+ content: [
426
+ {
427
+ type: 'paragraph',
428
+ content: [{ type: 'text', text: 'Parent bullet' }],
429
+ },
430
+ {
431
+ type: 'orderedList',
432
+ attrs: { start: 3 },
433
+ content: [
434
+ {
435
+ type: 'listItem',
436
+ content: [
437
+ {
438
+ type: 'paragraph',
439
+ content: [{ type: 'text', text: 'Nested step' }],
440
+ },
441
+ ],
442
+ },
443
+ ],
444
+ },
445
+ ],
446
+ },
447
+ ],
448
+ },
449
+ {
450
+ type: 'taskList',
451
+ content: [
452
+ {
453
+ type: 'taskItem',
454
+ attrs: { checked: false },
455
+ content: [
456
+ {
457
+ type: 'paragraph',
458
+ content: [{ type: 'text', text: 'Pending' }],
459
+ },
460
+ ],
461
+ },
462
+ {
463
+ type: 'taskItem',
464
+ attrs: { checked: true },
465
+ content: [
466
+ {
467
+ type: 'paragraph',
468
+ content: [{ type: 'text', text: 'Finished' }],
469
+ },
470
+ ],
471
+ },
472
+ ],
473
+ },
474
+ ],
475
+ });
476
+
477
+ expect(copyDocument(editor)).toBe(
478
+ [
479
+ '- Parent bullet',
480
+ ' 3. Nested step',
481
+ '',
482
+ '- [ ] Pending',
483
+ '- [x] Finished',
484
+ ].join('\n')
485
+ );
486
+ });
487
+
488
+ it('retains headings, inline formatting, links, and hard breaks', () => {
489
+ const editor = createEditor({
490
+ type: 'doc',
491
+ content: [
492
+ {
493
+ type: 'heading',
494
+ attrs: { level: 2 },
495
+ content: [
496
+ { type: 'text', text: 'Bold', marks: [{ type: 'bold' }] },
497
+ { type: 'text', text: ' and ' },
498
+ { type: 'text', text: 'italic', marks: [{ type: 'italic' }] },
499
+ ],
500
+ },
501
+ {
502
+ type: 'paragraph',
503
+ content: [
504
+ {
505
+ type: 'text',
506
+ text: 'Docs',
507
+ marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
508
+ },
509
+ { type: 'hardBreak' },
510
+ { type: 'text', text: 'deleted', marks: [{ type: 'strike' }] },
511
+ ],
512
+ },
513
+ ],
514
+ });
515
+ const slice = editor.state.doc.slice(0, editor.state.doc.content.size);
516
+ let fromClipboardProp = '';
517
+ editor.view.someProp('clipboardTextSerializer', (serializer) => {
518
+ fromClipboardProp = serializer(slice, editor.view);
519
+ return true;
520
+ });
521
+
522
+ expect(fromClipboardProp).toBe(
523
+ '## **Bold** and *italic*\n\n[Docs](https://example.com)\n~~deleted~~'
524
+ );
525
+ });
526
+
527
+ it('creates clean plain text without Markdown formatting delimiters', () => {
528
+ const editor = createEditor({
529
+ type: 'doc',
530
+ content: [
531
+ {
532
+ type: 'heading',
533
+ attrs: { level: 2 },
534
+ content: [
535
+ { type: 'text', text: 'Important', marks: [{ type: 'bold' }] },
536
+ ],
537
+ },
538
+ {
539
+ type: 'paragraph',
540
+ content: [
541
+ {
542
+ type: 'text',
543
+ text: 'Read the docs',
544
+ marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
545
+ },
546
+ ],
547
+ },
548
+ {
549
+ type: 'bulletList',
550
+ content: [
551
+ {
552
+ type: 'listItem',
553
+ content: [
554
+ {
555
+ type: 'paragraph',
556
+ content: [{ type: 'text', text: 'First point' }],
557
+ },
558
+ ],
559
+ },
560
+ ],
561
+ },
562
+ ],
563
+ });
564
+
565
+ expect(copyDocumentAsPlainText(editor)).toBe(
566
+ 'Important\n\nRead the docs\n\n• First point'
567
+ );
568
+ });
569
+
570
+ it('uses readable checkbox markers and preserves ordered-list starts in plain text', () => {
571
+ const editor = createEditor({
572
+ type: 'doc',
573
+ content: [
574
+ {
575
+ type: 'taskList',
576
+ content: [
577
+ {
578
+ type: 'taskItem',
579
+ attrs: { checked: false },
580
+ content: [
581
+ {
582
+ type: 'paragraph',
583
+ content: [{ type: 'text', text: 'Todo' }],
584
+ },
585
+ ],
586
+ },
587
+ {
588
+ type: 'taskItem',
589
+ attrs: { checked: true },
590
+ content: [
591
+ {
592
+ type: 'paragraph',
593
+ content: [{ type: 'text', text: 'Done' }],
594
+ },
595
+ ],
596
+ },
597
+ ],
598
+ },
599
+ {
600
+ type: 'orderedList',
601
+ attrs: { start: 4 },
602
+ content: [
603
+ {
604
+ type: 'listItem',
605
+ content: [
606
+ {
607
+ type: 'paragraph',
608
+ content: [{ type: 'text', text: 'Continue' }],
609
+ },
610
+ ],
611
+ },
612
+ ],
613
+ },
614
+ ],
615
+ });
616
+
617
+ expect(copyDocumentAsPlainText(editor)).toBe(
618
+ '☐ Todo\n☑ Done\n\n4. Continue'
619
+ );
620
+ });
621
+
622
+ it('round-trips the reported task shape through plain clipboard text', () => {
623
+ const source = createEditor({
624
+ type: 'doc',
625
+ content: [
626
+ {
627
+ type: 'heading',
628
+ attrs: { level: 1 },
629
+ content: [{ type: 'text', text: 'CHANGES FOR THE SITE' }],
630
+ },
631
+ { type: 'paragraph' },
632
+ { type: 'paragraph' },
633
+ {
634
+ type: 'bulletList',
635
+ content: [
636
+ {
637
+ type: 'listItem',
638
+ content: [
639
+ {
640
+ type: 'paragraph',
641
+ content: [
642
+ {
643
+ type: 'text',
644
+ text: 'The new editor:',
645
+ marks: [{ type: 'bold' }],
646
+ },
647
+ { type: 'text', text: ' keep the compact layout.' },
648
+ ],
649
+ },
650
+ ],
651
+ },
652
+ ],
653
+ },
654
+ ],
655
+ });
656
+ const clipboardText = copyDocument(source);
657
+ const destination = createEditor({ type: 'doc', content: [] });
658
+ destination.commands.setContent(markdownToHtml(clipboardText));
659
+ const content = destination.getJSON().content ?? [];
660
+
661
+ expect(clipboardText).toBe(
662
+ '# CHANGES FOR THE SITE\n\n- **The new editor:** keep the compact layout.'
663
+ );
664
+ expect(content.map((node) => node.type).slice(0, 2)).toEqual([
665
+ 'heading',
666
+ 'bulletList',
667
+ ]);
668
+ expect(content[1]).toMatchObject({
669
+ content: [
670
+ {
671
+ content: [
672
+ {
673
+ content: [
674
+ { marks: [{ type: 'bold' }], text: 'The new editor:' },
675
+ { text: ' keep the compact layout.' },
676
+ ],
677
+ },
678
+ ],
679
+ },
680
+ ],
681
+ });
682
+ });
683
+ });
@@ -0,0 +1,41 @@
1
+ const IMAGE_EXTENSIONS_BY_MIME_TYPE: Readonly<Record<string, string>> = {
2
+ 'image/avif': 'avif',
3
+ 'image/gif': 'gif',
4
+ 'image/jpeg': 'jpg',
5
+ 'image/jpg': 'jpg',
6
+ 'image/png': 'png',
7
+ 'image/svg+xml': 'svg',
8
+ 'image/webp': 'webp',
9
+ };
10
+
11
+ function normalizeClipboardImageFile(file: File, clipboardType: string): File {
12
+ const type = file.type || clipboardType;
13
+ const normalizedType = type.split(';', 1)[0]?.trim().toLowerCase() ?? '';
14
+ const extension = IMAGE_EXTENSIONS_BY_MIME_TYPE[normalizedType];
15
+ const currentName = file.name.trim();
16
+ const name =
17
+ currentName && (/\.[a-z0-9]+$/i.test(currentName) || !extension)
18
+ ? currentName
19
+ : `${currentName || 'pasted-image'}${extension ? `.${extension}` : ''}`;
20
+
21
+ if (name === file.name && type === file.type) {
22
+ return file;
23
+ }
24
+
25
+ return new File([file], name, {
26
+ lastModified: file.lastModified,
27
+ type,
28
+ });
29
+ }
30
+
31
+ export function getClipboardImageFiles(
32
+ items: DataTransferItemList | DataTransferItem[]
33
+ ): File[] {
34
+ return Array.from(items)
35
+ .map((item) => {
36
+ if (!item.type.startsWith('image/')) return null;
37
+ const file = item.getAsFile();
38
+ return file ? normalizeClipboardImageFile(file, item.type) : null;
39
+ })
40
+ .filter((file): file is File => file !== null);
41
+ }