@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.
@@ -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
+ }
@@ -182,7 +182,7 @@ function ColorControlPopover({
182
182
  aria-label={label}
183
183
  aria-pressed={active}
184
184
  className={cn(
185
- 'relative h-8 w-8 rounded-md border border-transparent transition-colors hover:bg-dynamic-surface/80',
185
+ 'relative h-8 w-8 shrink-0 rounded-md border border-transparent transition-colors hover:bg-dynamic-surface/80',
186
186
  active &&
187
187
  'border-foreground/10 bg-dynamic-surface/80 text-foreground'
188
188
  )}
@@ -0,0 +1,129 @@
1
+ 'use client';
2
+
3
+ import type { Editor } from '@tiptap/react';
4
+ import { Code2, Copy, FileText } from '@tuturuuu/icons';
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuItem,
9
+ DropdownMenuLabel,
10
+ DropdownMenuSeparator,
11
+ DropdownMenuTrigger,
12
+ } from '@tuturuuu/ui/dropdown-menu';
13
+ import { toast } from '@tuturuuu/ui/sonner';
14
+ import { Toggle } from '@tuturuuu/ui/toggle';
15
+ import { useCallback, useState } from 'react';
16
+ import {
17
+ serializeClipboardPlainText,
18
+ serializeClipboardText,
19
+ } from './clipboard-serialization';
20
+ import { TOOLBAR_BUTTON_CLASS_NAME } from './toolbar-controls';
21
+
22
+ export interface EditorCopyLabels {
23
+ copy?: string;
24
+ copyAsMarkdown?: string;
25
+ copyAsPlainText?: string;
26
+ markdownDescription?: string;
27
+ plainTextDescription?: string;
28
+ markdownCopied?: string;
29
+ plainTextCopied?: string;
30
+ copyFailed?: string;
31
+ }
32
+
33
+ interface EditorCopyMenuProps {
34
+ editor: Editor;
35
+ labels?: EditorCopyLabels;
36
+ }
37
+
38
+ const DEFAULT_LABELS = {
39
+ copy: 'Copy content',
40
+ copyAsMarkdown: 'Copy as Markdown',
41
+ copyAsPlainText: 'Copy as plain text',
42
+ markdownDescription: 'Keep headings, lists, links, and formatting syntax',
43
+ plainTextDescription: 'Clean text for chat, email, or documents',
44
+ markdownCopied: 'Copied as Markdown',
45
+ plainTextCopied: 'Copied as plain text',
46
+ copyFailed: 'Could not copy content',
47
+ } satisfies Required<EditorCopyLabels>;
48
+
49
+ export function EditorCopyMenu({ editor, labels }: EditorCopyMenuProps) {
50
+ const [open, setOpen] = useState(false);
51
+ const copy = useCallback(
52
+ async (format: 'markdown' | 'text') => {
53
+ try {
54
+ if (!navigator.clipboard?.writeText) throw new Error('Unavailable');
55
+ const slice = editor.state.doc.slice(0, editor.state.doc.content.size);
56
+ const content =
57
+ format === 'markdown'
58
+ ? serializeClipboardText(slice)
59
+ : serializeClipboardPlainText(slice);
60
+ await navigator.clipboard.writeText(content);
61
+ toast.success(
62
+ format === 'markdown'
63
+ ? (labels?.markdownCopied ?? DEFAULT_LABELS.markdownCopied)
64
+ : (labels?.plainTextCopied ?? DEFAULT_LABELS.plainTextCopied)
65
+ );
66
+ } catch {
67
+ toast.error(labels?.copyFailed ?? DEFAULT_LABELS.copyFailed);
68
+ }
69
+ },
70
+ [editor, labels]
71
+ );
72
+
73
+ const copyLabel = labels?.copy ?? DEFAULT_LABELS.copy;
74
+ const copyAndClose = (format: 'markdown' | 'text') => {
75
+ setOpen(false);
76
+ void copy(format);
77
+ };
78
+
79
+ return (
80
+ <DropdownMenu open={open} onOpenChange={setOpen}>
81
+ <DropdownMenuTrigger asChild>
82
+ <Toggle
83
+ pressed={open}
84
+ disabled={editor.isEmpty}
85
+ aria-label={copyLabel}
86
+ className={`${TOOLBAR_BUTTON_CLASS_NAME} data-[state=open]:border-foreground/10 data-[state=open]:bg-dynamic-surface/80 data-[state=open]:text-foreground`}
87
+ >
88
+ <Copy className="size-4" />
89
+ </Toggle>
90
+ </DropdownMenuTrigger>
91
+ <DropdownMenuContent align="end" className="w-72">
92
+ <DropdownMenuLabel className="text-muted-foreground text-xs">
93
+ {copyLabel}
94
+ </DropdownMenuLabel>
95
+ <DropdownMenuSeparator />
96
+ <DropdownMenuItem
97
+ className="items-start gap-3 py-2.5"
98
+ onSelect={() => copyAndClose('markdown')}
99
+ >
100
+ <Code2 className="mt-0.5 size-4 shrink-0 text-dynamic-blue" />
101
+ <span className="min-w-0">
102
+ <span className="block font-medium">
103
+ {labels?.copyAsMarkdown ?? DEFAULT_LABELS.copyAsMarkdown}
104
+ </span>
105
+ <span className="block whitespace-normal text-muted-foreground text-xs leading-relaxed">
106
+ {labels?.markdownDescription ??
107
+ DEFAULT_LABELS.markdownDescription}
108
+ </span>
109
+ </span>
110
+ </DropdownMenuItem>
111
+ <DropdownMenuItem
112
+ className="items-start gap-3 py-2.5"
113
+ onSelect={() => copyAndClose('text')}
114
+ >
115
+ <FileText className="mt-0.5 size-4 shrink-0 text-dynamic-green" />
116
+ <span className="min-w-0">
117
+ <span className="block font-medium">
118
+ {labels?.copyAsPlainText ?? DEFAULT_LABELS.copyAsPlainText}
119
+ </span>
120
+ <span className="block whitespace-normal text-muted-foreground text-xs leading-relaxed">
121
+ {labels?.plainTextDescription ??
122
+ DEFAULT_LABELS.plainTextDescription}
123
+ </span>
124
+ </span>
125
+ </DropdownMenuItem>
126
+ </DropdownMenuContent>
127
+ </DropdownMenu>
128
+ );
129
+ }
@@ -15,6 +15,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
15
  import { flushSync } from 'react-dom';
16
16
  import type * as Y from 'yjs';
17
17
  import { migrateInlineImagesToBlock } from './content-migration';
18
+ import type { EditorCopyLabels } from './copy-menu';
18
19
  import { getRichTextEditorClasses } from './editor-classes';
19
20
  import { getEditorExtensions } from './extensions';
20
21
  import { handleListIndentation, handlePlainEnterFallback } from './keyboard';
@@ -129,7 +130,9 @@ export interface RichTextEditorProps {
129
130
  create_project?: string;
130
131
  };
131
132
  renderTaskMention?: TaskMentionNodeViewRenderer;
133
+ toolbarLeadingContent?: React.ReactNode;
132
134
  toggleBlockLabel?: string;
135
+ copyLabels?: EditorCopyLabels;
133
136
  }
134
137
 
135
138
  export function RichTextEditor({
@@ -158,7 +161,9 @@ export function RichTextEditor({
158
161
  revealToolbarOnFocus = false,
159
162
  mentionTranslations,
160
163
  renderTaskMention,
164
+ toolbarLeadingContent,
161
165
  toggleBlockLabel,
166
+ copyLabels,
162
167
  }: RichTextEditorProps) {
163
168
  // Use refs to ensure we have stable references for handlers
164
169
  const onImageUploadRef = useRef(onImageUpload);
@@ -635,7 +640,9 @@ export function RichTextEditor({
635
640
  workspaceId={workspaceId}
636
641
  onImageUpload={onImageUpload}
637
642
  onConvertToTask={onConvertToTask}
643
+ leadingContent={toolbarLeadingContent}
638
644
  toggleBlockLabel={toggleBlockLabel}
645
+ copyLabels={copyLabels}
639
646
  />
640
647
  )}
641
648
  {!readOnly && (
@@ -7,6 +7,7 @@ import {
7
7
  import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
8
8
  import { toast } from '@tuturuuu/ui/sonner';
9
9
  import ImageResize from 'tiptap-extension-resize-image';
10
+ import { getClipboardImageFiles } from './clipboard-image-files';
10
11
  import {
11
12
  formatBytes,
12
13
  getImageDimensions,
@@ -88,6 +89,7 @@ function resolveUploadHandler({
88
89
  export const __imageExtensionPrivate = {
89
90
  clearImageResizeUIFromNodeDom,
90
91
  getSelectedImagePos,
92
+ getClipboardImageFiles,
91
93
  resolveUploadHandler,
92
94
  };
93
95
 
@@ -437,23 +439,10 @@ export const CustomImage = (options: ImageOptions = {}) => {
437
439
  if (!items) return false;
438
440
 
439
441
  // Filter and collect image files
440
- const images = Array.from(items)
441
- .map((item) =>
442
- item.type.startsWith('image/') ? item.getAsFile() : null
443
- )
444
- .filter((file): file is File => file !== null);
442
+ const images = getClipboardImageFiles(items);
445
443
 
446
444
  if (images.length === 0) return false;
447
445
 
448
- const hasTextOrHtml = Array.from(items).some(
449
- (item) =>
450
- item.type === 'text/plain' || item.type === 'text/html'
451
- );
452
-
453
- if (hasTextOrHtml) {
454
- return false;
455
- }
456
-
457
446
  const onImageUpload = getOnImageUpload();
458
447
  if (!onImageUpload) {
459
448
  event.preventDefault();
@@ -1,6 +1,10 @@
1
1
  import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model';
2
2
  import { Plugin, PluginKey } from '@tiptap/pm/state';
3
3
  import { Extension } from '@tiptap/react';
4
+ import {
5
+ collapseExcessBlankLines,
6
+ serializeClipboardText,
7
+ } from './clipboard-serialization';
4
8
 
5
9
  // ---------------------------------------------------------------------------
6
10
  // Markdown -> HTML converter (toolbar-supported features only)
@@ -27,6 +31,25 @@ const BLOCK_START_PATTERNS = [
27
31
  /^\s*\|/,
28
32
  ];
29
33
 
34
+ const VISUAL_BULLET_PATTERN = /^(\s*)[•◦▪‣●]\s+/u;
35
+ const VISUAL_CHECKBOX_PATTERN = /^(\s*)[☐☑☒]\s+/u;
36
+
37
+ function normalizePastedPlainText(text: string): string {
38
+ return collapseExcessBlankLines(text)
39
+ .split('\n')
40
+ .map((line) => {
41
+ const checkboxMatch = line.match(VISUAL_CHECKBOX_PATTERN);
42
+ if (checkboxMatch) {
43
+ const marker = line.trimStart().charAt(0);
44
+ const content = line.replace(VISUAL_CHECKBOX_PATTERN, '');
45
+ return `${checkboxMatch[1] ?? ''}- [${marker === '☐' ? ' ' : 'x'}] ${content}`;
46
+ }
47
+
48
+ return line.replace(VISUAL_BULLET_PATTERN, '$1- ');
49
+ })
50
+ .join('\n');
51
+ }
52
+
30
53
  function isBlockStart(line: string): boolean {
31
54
  return BLOCK_START_PATTERNS.some((p) => p.test(line));
32
55
  }
@@ -194,10 +217,7 @@ function parseListTree(
194
217
 
195
218
  while (i < lines.length) {
196
219
  const line = lines[i];
197
- if (line === undefined || line.trim() === '') {
198
- i++;
199
- continue;
200
- }
220
+ if (line === undefined || line.trim() === '') break;
201
221
 
202
222
  const match = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
203
223
  if (!match) break;
@@ -309,8 +329,15 @@ function renderListTree(nodes: ListNode[], ordered: boolean): string {
309
329
  const tag = ordered ? 'ol' : 'ul';
310
330
  const isTaskList = nodes.some((n) => n.checked !== null);
311
331
  const dataType = isTaskList ? ' data-type="taskList"' : '';
332
+ const orderedStart = ordered
333
+ ? Number.parseInt(nodes[0]?.marker.replace('.', '') ?? '1', 10)
334
+ : 1;
335
+ const startAttribute =
336
+ ordered && Number.isFinite(orderedStart) && orderedStart !== 1
337
+ ? ` start="${orderedStart}"`
338
+ : '';
312
339
 
313
- let html = `<${tag}${dataType}>`;
340
+ let html = `<${tag}${startAttribute}${dataType}>`;
314
341
 
315
342
  for (const node of nodes) {
316
343
  if (isTaskList) {
@@ -496,7 +523,7 @@ function markdownToHtml(markdown: string): string {
496
523
  paraLines.push(lines[i]!);
497
524
  i++;
498
525
  }
499
- result.push(`<p>${parseInline(paraLines.join(' '))}</p>`);
526
+ result.push(`<p>${paraLines.map(parseInline).join('<br>')}</p>`);
500
527
  }
501
528
 
502
529
  return result.join('\n');
@@ -532,6 +559,19 @@ function looksLikeMarkdown(text: string): boolean {
532
559
  return MARKDOWN_SIGNATURES.some((pattern) => pattern.test(text));
533
560
  }
534
561
 
562
+ const SEMANTIC_RICH_TEXT_TAG_PATTERN =
563
+ /<(?:a|b|blockquote|code|details|em|h[1-6]|i|img|li|mark|ol|pre|s|strong|summary|table|tbody|td|th|thead|tr|ul)\b/i;
564
+
565
+ function shouldConvertPastedText({
566
+ html,
567
+ text,
568
+ }: {
569
+ html: string;
570
+ text: string;
571
+ }): boolean {
572
+ return looksLikeMarkdown(text) && !SEMANTIC_RICH_TEXT_TAG_PATTERN.test(html);
573
+ }
574
+
535
575
  // ---------------------------------------------------------------------------
536
576
  // Test-only exports (not part of the public API contract)
537
577
  // ---------------------------------------------------------------------------
@@ -539,6 +579,8 @@ function looksLikeMarkdown(text: string): boolean {
539
579
  export const __markdownPastePrivate = {
540
580
  markdownToHtml,
541
581
  looksLikeMarkdown,
582
+ normalizePastedPlainText,
583
+ shouldConvertPastedText,
542
584
  };
543
585
 
544
586
  const markdownPastePluginKey = new PluginKey('markdownPastePlugin');
@@ -556,6 +598,7 @@ export const MarkdownPaste = Extension.create({
556
598
  new Plugin({
557
599
  key: markdownPastePluginKey,
558
600
  props: {
601
+ clipboardTextSerializer: serializeClipboardText,
559
602
  handleDOMEvents: {
560
603
  paste: (
561
604
  view: import('@tiptap/pm/view').EditorView,
@@ -569,25 +612,30 @@ export const MarkdownPaste = Extension.create({
569
612
  return false;
570
613
  }
571
614
 
572
- const text = clipboardData.getData('text/plain');
573
- if (!text || !looksLikeMarkdown(text)) {
615
+ const text = normalizePastedPlainText(
616
+ clipboardData.getData('text/plain')
617
+ );
618
+ const clipboardHtml = clipboardData.getData('text/html');
619
+ if (
620
+ !text ||
621
+ !shouldConvertPastedText({ html: clipboardHtml, text })
622
+ ) {
574
623
  return false;
575
624
  }
576
625
 
577
- // When plain text looks like markdown, convert it to editor nodes
578
- // even if HTML is also present on the clipboard. Many apps wrap
579
- // markdown in simple HTML tags that Tiptap would insert as plain
580
- // text paragraphs; we prefer structured conversion instead.
626
+ // Convert Markdown from plain text when HTML is absent or only a
627
+ // visual wrapper. Genuine rich HTML keeps its headings, lists,
628
+ // inline emphasis, links, media, and paragraph boundaries.
581
629
  event.preventDefault();
582
630
 
583
- const html = markdownToHtml(text);
631
+ const generatedHtml = markdownToHtml(text);
584
632
  const { state } = view;
585
633
  const { from, to } = state.selection;
586
634
 
587
635
  // Parse the generated HTML into a ProseMirror slice
588
636
  const browserParser = new DOMParser();
589
637
  const dom = browserParser.parseFromString(
590
- `<div>${html}</div>`,
638
+ `<div>${generatedHtml}</div>`,
591
639
  'text/html'
592
640
  );
593
641
  const firstChild = dom.body.firstChild;