Skip to content

Commit eb629b5

Browse files
committed
feat(Clipboard): added trim empty list items before copying
1 parent b69deff commit eb629b5

File tree

3 files changed

+171
-4
lines changed

3 files changed

+171
-4
lines changed

src/extensions/behavior/Clipboard/clipboard.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ import {serializeForClipboard} from '../../../utils/serialize-for-clipboard';
1111
import {BaseNode, pType} from '../../base/BaseSchema';
1212

1313
import {isInsideCode} from './code';
14-
import {DataTransferType, extractTextContentFromHtml, isIosSafariShare} from './utils';
14+
import {
15+
DataTransferType,
16+
extractTextContentFromHtml,
17+
isIosSafariShare,
18+
trimListItems,
19+
} from './utils';
1520

1621
export type ClipboardPluginOptions = {
1722
logger: Logger2.ILogger;
@@ -236,14 +241,20 @@ function serializeSelected(view: EditorView, serializer: Serializer): SerializeR
236241
const sel = view.state.selection;
237242

238243
if (sel.empty) return null;
244+
let content = sel.content();
239245

240246
if (getSharedDepthNode(sel).type.spec.code) {
241-
const fragment = sel.content().content;
247+
const fragment = content.content;
242248
return {text: fragment.textBetween(0, fragment.size)};
243249
}
244250

245-
// FIXME: Verify and use Node instead of Fragment
246-
const markup = serializer.serialize(getCopyContent(view.state).content as any);
251+
const trimedContent = trimListItems(content.content);
252+
253+
if (trimedContent !== content.content) {
254+
content = new Slice(trimedContent, content.openStart, content.openEnd);
255+
}
256+
257+
const markup = serializer.serialize(content.content as any);
247258
const {dom, text} = serializeForClipboard(view, sel.content());
248259
return {markup, text, html: dom.innerHTML};
249260
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import {Schema} from 'prosemirror-model';
2+
import {builders} from 'prosemirror-test-builder';
3+
4+
import {trimListItems} from './utils';
5+
6+
const schema = new Schema({
7+
nodes: {
8+
doc: {content: 'block+'},
9+
text: {group: 'inline'},
10+
paragraph: {group: 'block', content: 'text*', toDOM: () => ['p', 0]},
11+
bullet_list: {content: 'list_item+', group: 'block'},
12+
ordered_list: {content: 'list_item+', group: 'block'},
13+
list_item: {content: 'block+'},
14+
},
15+
marks: {},
16+
});
17+
18+
const {doc, paragraph: p} = builders(schema);
19+
const bulletList = builders(schema)['bullet_list'];
20+
const orderedList = builders(schema)['ordered_list'];
21+
const listItem = builders(schema)['list_item'];
22+
23+
describe('trimListItems', () => {
24+
it('removes empty list items at the start and end of a bullet list', () => {
25+
const fragment = doc(
26+
bulletList(listItem(), listItem(p('11')), listItem(p('22')), listItem()),
27+
).content;
28+
29+
const trimmedFragment = trimListItems(fragment);
30+
expect(schema.nodes.doc.create(null, trimmedFragment)).toMatchNode(
31+
doc(bulletList(listItem(p('11')), listItem(p('22')))),
32+
);
33+
});
34+
35+
it('removes empty list items at the start and end of an ordered list', () => {
36+
const fragment = doc(
37+
orderedList(listItem(), listItem(p('11')), listItem(p('22')), listItem()),
38+
).content;
39+
40+
const trimmedFragment = trimListItems(fragment);
41+
expect(schema.nodes.doc.create(null, trimmedFragment)).toMatchNode(
42+
doc(orderedList(listItem(p('11')), listItem(p('22')))),
43+
);
44+
});
45+
46+
it('removes only empty list items at the start and end, keeping empty ones in the middle', () => {
47+
const fragment = doc(
48+
bulletList(listItem(), listItem(p('11')), listItem(), listItem(p('22')), listItem()),
49+
).content;
50+
51+
const trimmedFragment = trimListItems(fragment);
52+
expect(schema.nodes.doc.create(null, trimmedFragment)).toMatchNode(
53+
doc(bulletList(listItem(p('11')), listItem(), listItem(p('22')))),
54+
);
55+
});
56+
57+
it('does not modify a list with no empty items', () => {
58+
const fragment = doc(bulletList(listItem(p('11')), listItem(p('22')))).content;
59+
60+
const trimmedFragment = trimListItems(fragment);
61+
expect(schema.nodes.doc.create(null, trimmedFragment)).toMatchNode(
62+
doc(bulletList(listItem(p('11')), listItem(p('22')))),
63+
);
64+
});
65+
66+
it('removes an entire list if it contains only empty list items', () => {
67+
const fragment = doc(bulletList(listItem(), listItem(), listItem())).content;
68+
69+
const trimmedFragment = trimListItems(fragment);
70+
expect(schema.nodes.doc.create(null, trimmedFragment)).toMatchNode(doc());
71+
});
72+
73+
it('correctly handles multiple lists in a single fragment', () => {
74+
const fragment = doc(
75+
bulletList(listItem(), listItem(p('11')), listItem(), listItem(p('22')), listItem()),
76+
orderedList(listItem(), listItem(p('A')), listItem(p('B')), listItem()),
77+
).content;
78+
79+
const trimmedFragment = trimListItems(fragment);
80+
expect(schema.nodes.doc.create(null, trimmedFragment)).toMatchNode(
81+
doc(
82+
bulletList(listItem(p('11')), listItem(), listItem(p('22'))),
83+
orderedList(listItem(p('A')), listItem(p('B'))),
84+
),
85+
);
86+
});
87+
88+
it('does not modify paragraphs and other nodes outside lists', () => {
89+
const fragment = doc(
90+
p('Some text'),
91+
bulletList(listItem(), listItem(p('11')), listItem(p('22')), listItem()),
92+
p('More text'),
93+
).content;
94+
95+
const trimmedFragment = trimListItems(fragment);
96+
expect(schema.nodes.doc.create(null, trimmedFragment)).toMatchNode(
97+
doc(p('Some text'), bulletList(listItem(p('11')), listItem(p('22'))), p('More text')),
98+
);
99+
});
100+
});

src/extensions/behavior/Clipboard/utils.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import {Fragment, type Node} from 'prosemirror-model';
2+
13
/** Сontains all data formats known to us */
24
export enum DataTransferType {
35
Text = 'text/plain',
@@ -56,3 +58,57 @@ export function extractTextContentFromHtml(html: string) {
5658

5759
return null;
5860
}
61+
62+
export function trimListItems(fragment: Fragment): Fragment {
63+
let modified = false;
64+
const newChildren: Node[] = [];
65+
66+
fragment.forEach((contentNode) => {
67+
if (['bullet_list', 'ordered_list'].includes(contentNode.type.name)) {
68+
const listItems: Node[] = [];
69+
let firstNonEmptyFound = false;
70+
let lastNonEmptyIndex = -1;
71+
72+
contentNode.forEach((node) => {
73+
if (node.type.name === 'list_item') {
74+
let hasContent = false;
75+
76+
node.content.forEach((child) => {
77+
if (
78+
(child.isText && child.text?.trim().length) ||
79+
(!child.isText && child.content.size > 0)
80+
) {
81+
hasContent = true;
82+
}
83+
});
84+
85+
if (hasContent) {
86+
firstNonEmptyFound = true;
87+
lastNonEmptyIndex = listItems.length;
88+
} else if (!firstNonEmptyFound) {
89+
modified = true;
90+
return;
91+
}
92+
}
93+
94+
listItems.push(node);
95+
});
96+
97+
if (lastNonEmptyIndex >= 0 && listItems.length > lastNonEmptyIndex + 1) {
98+
modified = true;
99+
}
100+
101+
if (modified) {
102+
listItems.length = lastNonEmptyIndex + 1;
103+
}
104+
105+
if (listItems.length > 0) {
106+
newChildren.push(contentNode.copy(Fragment.fromArray(listItems)));
107+
}
108+
} else {
109+
newChildren.push(contentNode);
110+
}
111+
});
112+
113+
return modified ? Fragment.fromArray(newChildren) : fragment;
114+
}

0 commit comments

Comments
 (0)