@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.
@@ -1,8 +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, looksLikeMarkdown, normalizePastedPlainText } =
5
- __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
+ });
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}](${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
+ }