@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.
- package/CHANGELOG.md +23 -0
- package/package.json +3 -3
- package/src/components/ui/calendar.tsx +6 -6
- package/src/components/ui/date-time-picker-layout.ts +6 -0
- package/src/components/ui/date-time-picker.test.tsx +25 -0
- package/src/components/ui/date-time-picker.tsx +5 -5
- package/src/components/ui/text-editor/__tests__/image-extension-clipboard.test.ts +87 -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 +419 -2
- 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 +3 -14
- package/src/components/ui/text-editor/markdown-paste-extension.ts +62 -14
- package/src/components/ui/text-editor/tool-bar.tsx +17 -69
- package/src/components/ui/text-editor/toolbar-controls.tsx +59 -0
|
@@ -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
|
+
}
|
|
@@ -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 =
|
|
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>${
|
|
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 =
|
|
573
|
-
|
|
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
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
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
|
|
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>${
|
|
638
|
+
`<div>${generatedHtml}</div>`,
|
|
591
639
|
'text/html'
|
|
592
640
|
);
|
|
593
641
|
const firstChild = dom.body.firstChild;
|