@tuturuuu/ui 0.28.1 → 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 +16 -0
- package/package.json +3 -3
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.29.0](https://github.com/tutur3u/platform/compare/ui-v0.28.1...ui-v0.29.0) (2026-08-20)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **editor:** add copy format menu ([389a6f0](https://github.com/tutur3u/platform/commit/389a6f02d822feef271409a9ab31e8fcbb6ef18e))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **editor:** contain toolbar overflow ([6bda614](https://github.com/tutur3u/platform/commit/6bda61416cbd2e4d4d4af7c7337d7b4e905274ef))
|
|
14
|
+
* **editor:** keep copy menu in toolbar ([c9dd241](https://github.com/tutur3u/platform/commit/c9dd2416f7e1b397c3293445d0bd0564a48c6bda))
|
|
15
|
+
* **editor:** preserve semantic rich paste ([bea67ca](https://github.com/tutur3u/platform/commit/bea67cac57b52b82a9ed4d7fd1a8a892cf4ceea4))
|
|
16
|
+
* **editor:** preserve task clipboard structure ([2d05d86](https://github.com/tutur3u/platform/commit/2d05d8602f39975805944edfccb2e10818863413))
|
|
17
|
+
* **tasks:** restore pasted image uploads ([3edef4e](https://github.com/tutur3u/platform/commit/3edef4e04a1fc6963904b7861d422f8db9d5b749))
|
|
18
|
+
|
|
3
19
|
## [0.28.1](https://github.com/tutur3u/platform/compare/ui-v0.28.0...ui-v0.28.1) (2026-08-16)
|
|
4
20
|
|
|
5
21
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -91,9 +91,9 @@
|
|
|
91
91
|
"@tuturuuu/apis": "0.11.2",
|
|
92
92
|
"@tuturuuu/hooks": "0.1.1",
|
|
93
93
|
"@tuturuuu/icons": "0.1.0",
|
|
94
|
-
"@tuturuuu/internal-api": "0.31.
|
|
94
|
+
"@tuturuuu/internal-api": "0.31.1",
|
|
95
95
|
"@tuturuuu/supabase": "0.5.0",
|
|
96
|
-
"@tuturuuu/utils": "0.25.
|
|
96
|
+
"@tuturuuu/utils": "0.25.3",
|
|
97
97
|
"@types/debug": "^4.1.13",
|
|
98
98
|
"browser-image-compression": "^2.0.2",
|
|
99
99
|
"class-variance-authority": "^0.7.1",
|
|
@@ -18,4 +18,70 @@ describe('ImageExtension clipboard files', () => {
|
|
|
18
18
|
|
|
19
19
|
expect(images).toEqual([imageFile]);
|
|
20
20
|
});
|
|
21
|
+
|
|
22
|
+
it('names an unnamed screenshot from its clipboard MIME type', () => {
|
|
23
|
+
const unnamedImage = new File(['image'], '', { type: 'image/png' });
|
|
24
|
+
|
|
25
|
+
const [image] = __imageExtensionPrivate.getClipboardImageFiles([
|
|
26
|
+
{
|
|
27
|
+
type: 'image/png',
|
|
28
|
+
getAsFile: () => unnamedImage,
|
|
29
|
+
} as DataTransferItem,
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
expect(image).toBeInstanceOf(File);
|
|
33
|
+
expect(image?.name).toBe('pasted-image.png');
|
|
34
|
+
expect(image?.type).toBe('image/png');
|
|
35
|
+
expect(image?.size).toBe(unnamedImage.size);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('adds an extension to an extensionless clipboard filename', () => {
|
|
39
|
+
const extensionlessImage = new File(['image'], 'Screenshot', {
|
|
40
|
+
type: 'image/jpeg',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const [image] = __imageExtensionPrivate.getClipboardImageFiles([
|
|
44
|
+
{
|
|
45
|
+
type: 'image/jpeg',
|
|
46
|
+
getAsFile: () => extensionlessImage,
|
|
47
|
+
} as DataTransferItem,
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
expect(image?.name).toBe('Screenshot.jpg');
|
|
51
|
+
expect(image?.type).toBe('image/jpeg');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('restores a missing file MIME type from the clipboard item', () => {
|
|
55
|
+
const imageWithoutType = new File(['image'], '', { type: '' });
|
|
56
|
+
|
|
57
|
+
const [image] = __imageExtensionPrivate.getClipboardImageFiles([
|
|
58
|
+
{
|
|
59
|
+
type: 'image/webp',
|
|
60
|
+
getAsFile: () => imageWithoutType,
|
|
61
|
+
} as DataTransferItem,
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
expect(image?.name).toBe('pasted-image.webp');
|
|
65
|
+
expect(image?.type).toBe('image/webp');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('ignores non-image items and null image files without losing valid images', () => {
|
|
69
|
+
const imageFile = new File(['image'], 'capture.png', {
|
|
70
|
+
type: 'image/png',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const images = __imageExtensionPrivate.getClipboardImageFiles([
|
|
74
|
+
{
|
|
75
|
+
type: 'text/plain',
|
|
76
|
+
getAsFile: () => new File(['text'], 'note.txt'),
|
|
77
|
+
} as DataTransferItem,
|
|
78
|
+
{ type: 'image/png', getAsFile: () => null } as DataTransferItem,
|
|
79
|
+
{
|
|
80
|
+
type: 'image/png',
|
|
81
|
+
getAsFile: () => imageFile,
|
|
82
|
+
} as DataTransferItem,
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
expect(images).toEqual([imageFile]);
|
|
86
|
+
});
|
|
21
87
|
});
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { Editor } from '@tiptap/core';
|
|
3
|
+
import type { Editor as TiptapEditor } from '@tiptap/react';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { getEditorExtensions } from '../extensions';
|
|
4
6
|
import { FixedToolbar } from '../tool-bar';
|
|
5
7
|
|
|
6
|
-
function createEditorStub():
|
|
8
|
+
function createEditorStub(): TiptapEditor {
|
|
7
9
|
const chain = {
|
|
8
10
|
focus: vi.fn(() => chain),
|
|
9
11
|
insertTable: vi.fn(() => chain),
|
|
@@ -26,9 +28,16 @@ function createEditorStub(): Editor {
|
|
|
26
28
|
return {
|
|
27
29
|
chain: vi.fn(() => chain),
|
|
28
30
|
isActive: vi.fn(() => false),
|
|
29
|
-
} as unknown as
|
|
31
|
+
} as unknown as TiptapEditor;
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
const editors: Editor[] = [];
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
for (const editor of editors.splice(0)) editor.destroy();
|
|
38
|
+
vi.restoreAllMocks();
|
|
39
|
+
});
|
|
40
|
+
|
|
32
41
|
describe('inline task conversion toolbar', () => {
|
|
33
42
|
it('delegates conversion to the provided callback', async () => {
|
|
34
43
|
const onConvertToTask = vi.fn();
|
|
@@ -73,4 +82,106 @@ describe('inline task conversion toolbar', () => {
|
|
|
73
82
|
screen.getByRole('button', { name: 'Liste ou titre repliable' })
|
|
74
83
|
).toBeInTheDocument();
|
|
75
84
|
});
|
|
85
|
+
|
|
86
|
+
it('keeps every control in a scrollbar-free horizontal lane without shrinking', () => {
|
|
87
|
+
const { container } = render(
|
|
88
|
+
<FixedToolbar
|
|
89
|
+
editor={createEditorStub()}
|
|
90
|
+
leadingContent={<button type="button">99% left</button>}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
const toolbar = container.firstElementChild;
|
|
94
|
+
const capacityButton = screen.getByRole('button', { name: '99% left' });
|
|
95
|
+
const boldButton = screen.getByRole('button', { name: 'Bold' });
|
|
96
|
+
const copyButton = screen.getByRole('button', { name: 'Copy content' });
|
|
97
|
+
|
|
98
|
+
expect(toolbar).toHaveClass(
|
|
99
|
+
'@container',
|
|
100
|
+
'flex-nowrap',
|
|
101
|
+
'max-w-full',
|
|
102
|
+
'overflow-x-auto',
|
|
103
|
+
'overflow-y-hidden',
|
|
104
|
+
'overscroll-x-contain',
|
|
105
|
+
'scrollbar-hide'
|
|
106
|
+
);
|
|
107
|
+
expect(toolbar).not.toHaveClass('flex-wrap');
|
|
108
|
+
expect(copyButton.closest('.ml-auto')).toBeNull();
|
|
109
|
+
expect(capacityButton.compareDocumentPosition(boldButton)).toBe(
|
|
110
|
+
Node.DOCUMENT_POSITION_FOLLOWING
|
|
111
|
+
);
|
|
112
|
+
expect(copyButton).toHaveClass('h-8', 'w-8', 'shrink-0', 'rounded-md');
|
|
113
|
+
expect(boldButton).toHaveClass('h-8', 'w-8', 'shrink-0', 'rounded-md');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('copies the full document in either Markdown or plain-text form', async () => {
|
|
117
|
+
const editor = new Editor({
|
|
118
|
+
extensions: getEditorExtensions(),
|
|
119
|
+
content: {
|
|
120
|
+
type: 'doc',
|
|
121
|
+
content: [
|
|
122
|
+
{
|
|
123
|
+
type: 'heading',
|
|
124
|
+
attrs: { level: 1 },
|
|
125
|
+
content: [{ type: 'text', text: 'Plan' }],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
type: 'bulletList',
|
|
129
|
+
content: [
|
|
130
|
+
{
|
|
131
|
+
type: 'listItem',
|
|
132
|
+
content: [
|
|
133
|
+
{
|
|
134
|
+
type: 'paragraph',
|
|
135
|
+
content: [
|
|
136
|
+
{
|
|
137
|
+
type: 'text',
|
|
138
|
+
text: 'Ship it',
|
|
139
|
+
marks: [{ type: 'bold' }],
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
editors.push(editor);
|
|
151
|
+
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
152
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
153
|
+
configurable: true,
|
|
154
|
+
value: { writeText },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
render(<FixedToolbar editor={editor} />);
|
|
158
|
+
|
|
159
|
+
fireEvent.pointerDown(
|
|
160
|
+
await screen.findByRole('button', { name: 'Copy content' })
|
|
161
|
+
);
|
|
162
|
+
fireEvent.click(
|
|
163
|
+
await screen.findByRole('menuitem', { name: /Copy as Markdown/ })
|
|
164
|
+
);
|
|
165
|
+
await waitFor(() =>
|
|
166
|
+
expect(
|
|
167
|
+
screen.queryByRole('menuitem', { name: /Copy as Markdown/ })
|
|
168
|
+
).not.toBeInTheDocument()
|
|
169
|
+
);
|
|
170
|
+
await waitFor(() =>
|
|
171
|
+
expect(writeText).toHaveBeenLastCalledWith('# Plan\n\n- **Ship it**')
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
fireEvent.pointerDown(screen.getByRole('button', { name: 'Copy content' }));
|
|
175
|
+
fireEvent.click(
|
|
176
|
+
await screen.findByRole('menuitem', { name: /Copy as plain text/ })
|
|
177
|
+
);
|
|
178
|
+
await waitFor(() =>
|
|
179
|
+
expect(
|
|
180
|
+
screen.queryByRole('menuitem', { name: /Copy as plain text/ })
|
|
181
|
+
).not.toBeInTheDocument()
|
|
182
|
+
);
|
|
183
|
+
await waitFor(() =>
|
|
184
|
+
expect(writeText).toHaveBeenLastCalledWith('Plan\n\n• Ship it')
|
|
185
|
+
);
|
|
186
|
+
});
|
|
76
187
|
});
|
|
@@ -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
|
+
});
|