@tuturuuu/ui 0.28.1 → 0.29.1
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/CHANGELOG.md +23 -0
- package/biome.json +1 -1
- package/package.json +53 -53
- package/src/components/ui/text-editor/__tests__/image-extension-clipboard.test.ts +66 -0
- package/src/components/ui/text-editor/__tests__/inline-task-conversion.test.tsx +116 -5
- package/src/components/ui/text-editor/__tests__/markdown-paste-extension.test.ts +392 -3
- package/src/components/ui/text-editor/clipboard-image-files.ts +41 -0
- package/src/components/ui/text-editor/clipboard-serialization.ts +249 -0
- package/src/components/ui/text-editor/color-controls.tsx +1 -1
- package/src/components/ui/text-editor/copy-menu.tsx +129 -0
- package/src/components/ui/text-editor/editor.tsx +7 -0
- package/src/components/ui/text-editor/image-extension.ts +1 -8
- package/src/components/ui/text-editor/markdown-paste-extension.ts +39 -14
- package/src/components/ui/text-editor/tool-bar.tsx +17 -69
- package/src/components/ui/text-editor/toolbar-controls.tsx +59 -0
|
@@ -1,8 +1,42 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
5
|
-
|
|
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
|
+
});
|
|
6
40
|
|
|
7
41
|
describe('markdownToHtml', () => {
|
|
8
42
|
it('should convert headings', () => {
|
|
@@ -69,6 +103,44 @@ describe('markdownToHtml', () => {
|
|
|
69
103
|
expect(markdownToHtml(text)).toContain('data-type="taskList"');
|
|
70
104
|
});
|
|
71
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
|
+
|
|
72
144
|
it('should convert ordered lists', () => {
|
|
73
145
|
const md = '1. first\n2. second';
|
|
74
146
|
const html = markdownToHtml(md);
|
|
@@ -85,6 +157,16 @@ describe('markdownToHtml', () => {
|
|
|
85
157
|
expect(html).toContain('data-checked="true"');
|
|
86
158
|
});
|
|
87
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
|
+
|
|
88
170
|
it('should convert nested unordered lists', () => {
|
|
89
171
|
const md = '- Item A\n - Nested A1\n - Nested A2\n- Item B';
|
|
90
172
|
const html = markdownToHtml(md);
|
|
@@ -108,6 +190,12 @@ describe('markdownToHtml', () => {
|
|
|
108
190
|
);
|
|
109
191
|
});
|
|
110
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
|
+
|
|
111
199
|
it('should convert deeply nested lists', () => {
|
|
112
200
|
const md = '- A\n - B\n - C\n - D';
|
|
113
201
|
const html = markdownToHtml(md);
|
|
@@ -292,3 +380,304 @@ describe('markdownToHtml', () => {
|
|
|
292
380
|
expect(html).not.toContain('<div>');
|
|
293
381
|
});
|
|
294
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
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { Fragment, Mark, Node, Slice } from '@tiptap/pm/model';
|
|
2
|
+
|
|
3
|
+
type ClipboardFormat = 'markdown' | 'text';
|
|
4
|
+
|
|
5
|
+
function wrapMarkedText(
|
|
6
|
+
text: string,
|
|
7
|
+
marks: readonly Mark[],
|
|
8
|
+
format: ClipboardFormat
|
|
9
|
+
): string {
|
|
10
|
+
if (format === 'text') return text;
|
|
11
|
+
|
|
12
|
+
const marksByName = new Map(marks.map((mark) => [mark.type.name, mark]));
|
|
13
|
+
let result = text;
|
|
14
|
+
|
|
15
|
+
if (marksByName.has('code')) {
|
|
16
|
+
const fence = result.includes('`') ? '``' : '`';
|
|
17
|
+
result = `${fence}${result}${fence}`;
|
|
18
|
+
}
|
|
19
|
+
if (marksByName.has('bold')) result = `**${result}**`;
|
|
20
|
+
if (marksByName.has('italic')) result = `*${result}*`;
|
|
21
|
+
if (marksByName.has('strike')) result = `~~${result}~~`;
|
|
22
|
+
if (marksByName.has('highlight')) result = `==${result}==`;
|
|
23
|
+
if (marksByName.has('subscript')) result = `<sub>${result}</sub>`;
|
|
24
|
+
if (marksByName.has('superscript')) result = `<sup>${result}</sup>`;
|
|
25
|
+
|
|
26
|
+
const link = marksByName.get('link');
|
|
27
|
+
const href = typeof link?.attrs.href === 'string' ? link.attrs.href : '';
|
|
28
|
+
return href ? `[${result}](${href})` : result;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function serializeInlineContent(node: Node, format: ClipboardFormat): string {
|
|
32
|
+
let result = '';
|
|
33
|
+
node.forEach((child) => {
|
|
34
|
+
if (child.isText) {
|
|
35
|
+
result += wrapMarkedText(child.text ?? '', child.marks, format);
|
|
36
|
+
} else if (child.type.name === 'hardBreak') {
|
|
37
|
+
result += '\n';
|
|
38
|
+
} else if (child.type.name === 'mention') {
|
|
39
|
+
result += child.attrs.displayName
|
|
40
|
+
? `@${String(child.attrs.displayName)}`
|
|
41
|
+
: '@mention';
|
|
42
|
+
} else if (
|
|
43
|
+
child.type.name === 'image' ||
|
|
44
|
+
child.type.name === 'imageResize'
|
|
45
|
+
) {
|
|
46
|
+
const src = typeof child.attrs.src === 'string' ? child.attrs.src : '';
|
|
47
|
+
const alt = typeof child.attrs.alt === 'string' ? child.attrs.alt : '';
|
|
48
|
+
if (format === 'markdown') result += src ? `` : alt;
|
|
49
|
+
else result += [alt || 'Image', src].filter(Boolean).join(': ');
|
|
50
|
+
} else {
|
|
51
|
+
result += serializeInlineContent(child, format);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function serializeListItem(
|
|
58
|
+
node: Node,
|
|
59
|
+
marker: string,
|
|
60
|
+
depth: number,
|
|
61
|
+
format: ClipboardFormat
|
|
62
|
+
): string {
|
|
63
|
+
const indent = ' '.repeat(depth);
|
|
64
|
+
const prefix = `${marker} `;
|
|
65
|
+
const lines: string[] = [];
|
|
66
|
+
let hasMarker = false;
|
|
67
|
+
|
|
68
|
+
node.forEach((child) => {
|
|
69
|
+
if (['bulletList', 'orderedList', 'taskList'].includes(child.type.name)) {
|
|
70
|
+
lines.push(serializeList(child, depth + 1, format));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const text = serializeBlock(child, depth, format).trimEnd();
|
|
75
|
+
if (!hasMarker) {
|
|
76
|
+
const continuation = ' '.repeat(prefix.length);
|
|
77
|
+
const marked = text
|
|
78
|
+
.split('\n')
|
|
79
|
+
.map((line, index) => (index === 0 ? line : `${continuation}${line}`))
|
|
80
|
+
.join('\n');
|
|
81
|
+
lines.push(`${indent}${prefix}${marked}`);
|
|
82
|
+
hasMarker = true;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const continuation = `${indent}${' '.repeat(prefix.length)}`;
|
|
87
|
+
lines.push(
|
|
88
|
+
text
|
|
89
|
+
.split('\n')
|
|
90
|
+
.map((line) => `${continuation}${line}`)
|
|
91
|
+
.join('\n')
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return lines.join('\n') || `${indent}${prefix.trimEnd()}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function serializeList(
|
|
99
|
+
node: Node,
|
|
100
|
+
depth: number,
|
|
101
|
+
format: ClipboardFormat
|
|
102
|
+
): string {
|
|
103
|
+
const start =
|
|
104
|
+
node.type.name === 'orderedList' && typeof node.attrs.start === 'number'
|
|
105
|
+
? node.attrs.start
|
|
106
|
+
: 1;
|
|
107
|
+
const lines: string[] = [];
|
|
108
|
+
node.forEach((item, _offset, index) => {
|
|
109
|
+
const marker =
|
|
110
|
+
node.type.name === 'orderedList'
|
|
111
|
+
? `${start + index}.`
|
|
112
|
+
: node.type.name === 'taskList'
|
|
113
|
+
? format === 'markdown'
|
|
114
|
+
? item.attrs.checked === true
|
|
115
|
+
? '- [x]'
|
|
116
|
+
: '- [ ]'
|
|
117
|
+
: item.attrs.checked === true
|
|
118
|
+
? '☑'
|
|
119
|
+
: '☐'
|
|
120
|
+
: format === 'markdown'
|
|
121
|
+
? '-'
|
|
122
|
+
: '•';
|
|
123
|
+
lines.push(serializeListItem(item, marker, depth, format));
|
|
124
|
+
});
|
|
125
|
+
return lines.join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function serializeTable(node: Node, format: ClipboardFormat): string {
|
|
129
|
+
const rows: string[][] = [];
|
|
130
|
+
node.forEach((row) => {
|
|
131
|
+
const cells: string[] = [];
|
|
132
|
+
row.forEach((cell) => {
|
|
133
|
+
cells.push(
|
|
134
|
+
serializeBlocks(cell.content, format).replace(/\n+/g, ' ').trim()
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
rows.push(cells);
|
|
138
|
+
});
|
|
139
|
+
if (rows.length === 0) return '';
|
|
140
|
+
if (format === 'text') return rows.map((row) => row.join('\t')).join('\n');
|
|
141
|
+
|
|
142
|
+
const width = Math.max(...rows.map((row) => row.length));
|
|
143
|
+
const normalizedRows = rows.map((row) => [
|
|
144
|
+
...row,
|
|
145
|
+
...Array.from({ length: width - row.length }, () => ''),
|
|
146
|
+
]);
|
|
147
|
+
const separator = Array.from({ length: width }, () => '---');
|
|
148
|
+
return [normalizedRows[0] ?? [], separator, ...normalizedRows.slice(1)]
|
|
149
|
+
.map((row) => `| ${row.join(' | ')} |`)
|
|
150
|
+
.join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function serializeBlock(
|
|
154
|
+
node: Node,
|
|
155
|
+
depth = 0,
|
|
156
|
+
format: ClipboardFormat
|
|
157
|
+
): string {
|
|
158
|
+
switch (node.type.name) {
|
|
159
|
+
case 'paragraph':
|
|
160
|
+
return serializeInlineContent(node, format);
|
|
161
|
+
case 'heading': {
|
|
162
|
+
const text = serializeInlineContent(node, format);
|
|
163
|
+
if (format === 'text') return text;
|
|
164
|
+
const level = typeof node.attrs.level === 'number' ? node.attrs.level : 1;
|
|
165
|
+
return `${'#'.repeat(Math.min(6, Math.max(1, level)))} ${text}`;
|
|
166
|
+
}
|
|
167
|
+
case 'bulletList':
|
|
168
|
+
case 'orderedList':
|
|
169
|
+
case 'taskList':
|
|
170
|
+
return serializeList(node, depth, format);
|
|
171
|
+
case 'blockquote': {
|
|
172
|
+
const text = serializeBlocks(node.content, format);
|
|
173
|
+
return format === 'text'
|
|
174
|
+
? text
|
|
175
|
+
: text
|
|
176
|
+
.split('\n')
|
|
177
|
+
.map((line) => (line ? `> ${line}` : '>'))
|
|
178
|
+
.join('\n');
|
|
179
|
+
}
|
|
180
|
+
case 'codeBlock': {
|
|
181
|
+
if (format === 'text') return node.textContent;
|
|
182
|
+
const language =
|
|
183
|
+
typeof node.attrs.language === 'string' ? node.attrs.language : '';
|
|
184
|
+
return `\`\`\`${language}\n${node.textContent}\n\`\`\``;
|
|
185
|
+
}
|
|
186
|
+
case 'horizontalRule':
|
|
187
|
+
return '---';
|
|
188
|
+
case 'table':
|
|
189
|
+
return serializeTable(node, format);
|
|
190
|
+
case 'video':
|
|
191
|
+
return node.attrs.src
|
|
192
|
+
? format === 'markdown'
|
|
193
|
+
? `[Video](${String(node.attrs.src)})`
|
|
194
|
+
: `Video: ${String(node.attrs.src)}`
|
|
195
|
+
: '';
|
|
196
|
+
case 'details':
|
|
197
|
+
case 'detailsContent':
|
|
198
|
+
return serializeBlocks(node.content, format);
|
|
199
|
+
case 'detailsSummary': {
|
|
200
|
+
const summary = serializeInlineContent(node, format);
|
|
201
|
+
if (format === 'text') return summary;
|
|
202
|
+
const level =
|
|
203
|
+
typeof node.attrs.level === 'number' ? node.attrs.level : null;
|
|
204
|
+
return level ? `${'#'.repeat(level)} ${summary}` : summary;
|
|
205
|
+
}
|
|
206
|
+
default:
|
|
207
|
+
return serializeBlocks(node.content, format);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function serializeBlocks(fragment: Fragment, format: ClipboardFormat): string {
|
|
212
|
+
const blocks: string[] = [];
|
|
213
|
+
fragment.forEach((node) => {
|
|
214
|
+
blocks.push(serializeBlock(node, 0, format));
|
|
215
|
+
});
|
|
216
|
+
return blocks.join('\n\n');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function collapseExcessBlankLines(text: string): string {
|
|
220
|
+
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
|
221
|
+
const normalized: string[] = [];
|
|
222
|
+
let fenced = false;
|
|
223
|
+
for (const line of lines) {
|
|
224
|
+
const isFence = /^\s*(?:```|~~~)/.test(line);
|
|
225
|
+
if (isFence) fenced = !fenced;
|
|
226
|
+
if (!fenced && !isFence && line.trim() === '') {
|
|
227
|
+
if (normalized.at(-1) !== '') normalized.push('');
|
|
228
|
+
} else normalized.push(line);
|
|
229
|
+
}
|
|
230
|
+
return normalized.join('\n');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function serializeClipboard(slice: Slice, format: ClipboardFormat): string {
|
|
234
|
+
return collapseExcessBlankLines(
|
|
235
|
+
serializeBlocks(slice.content, format)
|
|
236
|
+
.replace(/\r\n?/g, '\n')
|
|
237
|
+
.replace(/[ \t]+$/gm, '')
|
|
238
|
+
).replace(/^\n+|\n+$/g, '');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Markdown representation used for normal clipboard copy and explicit export. */
|
|
242
|
+
export function serializeClipboardText(slice: Slice): string {
|
|
243
|
+
return serializeClipboard(slice, 'markdown');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Readable plain text without Markdown formatting delimiters. */
|
|
247
|
+
export function serializeClipboardPlainText(slice: Slice): string {
|
|
248
|
+
return serializeClipboard(slice, 'text');
|
|
249
|
+
}
|