Skip to content

Commit 3f09b09

Browse files
committed
feat(Clipboard): updated trimListItems logic, renamed to trimContent
1 parent 669a196 commit 3f09b09

File tree

4 files changed

+182
-106
lines changed

4 files changed

+182
-106
lines changed

src/extensions/behavior/Clipboard/clipboard.ts

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

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

2116
export type ClipboardPluginOptions = {
2217
logger: Logger2.ILogger;
@@ -322,7 +317,7 @@ function getCopyContent(state: EditorState): Slice {
322317
}
323318
} else {
324319
// trim empty list items
325-
const trimedContent = trimListItems(slice.content);
320+
const trimedContent = trimContent(slice.content);
326321
if (trimedContent !== slice.content) {
327322
slice = new Slice(trimedContent, 1, 1);
328323
}
Lines changed: 143 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,195 @@
1-
import {Schema} from 'prosemirror-model';
21
import {builders} from 'prosemirror-test-builder';
32

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: {},
3+
import {findNotEmptyContentPosses, trimContent} from 'src/extensions/behavior/Clipboard/utils';
4+
import {CutAttr, CutNode, YfmCutSpecs} from 'src/extensions/yfm/YfmCut/YfmCutSpecs';
5+
6+
import {DirectiveContext} from '../../../../tests/utils';
7+
import {ExtensionsManager} from '../../../core';
8+
import {BaseNode, BaseSchemaSpecs} from '../../base/specs';
9+
import {
10+
BlockquoteSpecs,
11+
ImageSpecs,
12+
ItalicSpecs,
13+
ListNode,
14+
ListsSpecs,
15+
blockquoteNodeName,
16+
imageNodeName,
17+
italicMarkName,
18+
} from '../../markdown/specs';
19+
20+
const directiveContext = new DirectiveContext(undefined);
21+
22+
function buildDeps() {
23+
return new ExtensionsManager({
24+
extensions: (builder) => {
25+
builder.context.set('directiveSyntax', directiveContext);
26+
builder
27+
.use(BaseSchemaSpecs, {})
28+
.use(ItalicSpecs)
29+
.use(BlockquoteSpecs)
30+
.use(YfmCutSpecs, {})
31+
.use(ImageSpecs)
32+
.use(ListsSpecs, {});
33+
},
34+
}).buildDeps();
35+
}
36+
37+
const {schema} = buildDeps();
38+
39+
const {doc, p, cut, cutTitle, cutContent, li, ul, ol, bq, img} = builders<
40+
'doc' | 'p' | 'bq' | 'img' | 'cut' | 'cutTitle' | 'cutContent',
41+
'i' | 'li' | 'ul' | 'ol'
42+
>(schema, {
43+
doc: {nodeType: BaseNode.Doc},
44+
p: {nodeType: BaseNode.Paragraph},
45+
i: {markType: italicMarkName},
46+
bq: {nodeType: blockquoteNodeName},
47+
img: {nodeType: imageNodeName},
48+
cut: {nodeType: CutNode.Cut},
49+
cutTitle: {nodeType: CutNode.CutTitle},
50+
cutContent: {nodeType: CutNode.CutContent},
51+
li: {nodeType: ListNode.ListItem},
52+
ul: {nodeType: ListNode.BulletList},
53+
ol: {nodeType: ListNode.OrderedList},
1654
});
1755

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'];
56+
describe('findNotEmptyContentPosses', () => {
57+
it('identifies boundaries in a simple document', () => {
58+
const fragment = doc(p('11'), p('22')).content;
59+
expect(findNotEmptyContentPosses(fragment)).toEqual([0, 8, 0, 8]);
60+
});
2261

23-
describe('trimListItems', () => {
24-
it('removes empty list items at the start and end of a bullet list', () => {
62+
it('ignores empty paragraphs and detects valid content', () => {
63+
const fragment = doc(p(), p('11'), p('22'), p()).content;
64+
expect(findNotEmptyContentPosses(fragment)).toEqual([2, 10, 0, 12]);
65+
});
66+
67+
it('handles custom nodes correctly', () => {
2568
const fragment = doc(
26-
bulletList(listItem(), listItem(p('11')), listItem(p('22')), listItem()),
69+
cut({[CutAttr.Markup]: '{%'}, cutTitle(''), cutContent(p())),
70+
p('11'),
71+
p('22'),
2772
).content;
73+
expect(findNotEmptyContentPosses(fragment)).toEqual([0, 16, 0, 16]);
74+
});
75+
76+
it('skips empty list at start and finds content boundaries', () => {
77+
const fragment = doc(ul(li(p(''))), p('11'), p('22')).content;
78+
expect(findNotEmptyContentPosses(fragment)).toEqual([6, 14, 0, 14]);
79+
});
80+
81+
it('skips empty list between paragraphs', () => {
82+
const fragment = doc(p('11'), ul(li(p(''))), p('22')).content;
83+
expect(findNotEmptyContentPosses(fragment)).toEqual([0, 14, 0, 14]);
84+
});
85+
86+
it('ignores deeply nested empty lists', () => {
87+
const fragment = doc(ul(li(p(''), ul(li(p(''))))), p('11'), p('22')).content;
88+
expect(findNotEmptyContentPosses(fragment)).toEqual([12, 20, 0, 20]);
89+
});
90+
91+
it('handles multiple empty lists before and after content', () => {
92+
const fragment = doc(
93+
ul(li(p(''), ul(li(p(''))))),
94+
p('11'),
95+
p('22'),
96+
ul(li(p(''), ul(li(p(''))))),
97+
).content;
98+
expect(findNotEmptyContentPosses(fragment)).toEqual([12, 20, 0, 32]);
99+
});
100+
101+
it('identifies non-empty text inside a blockquote', () => {
102+
const fragment = doc(bq(p('Quote text'))).content;
103+
expect(findNotEmptyContentPosses(fragment)).toEqual([0, 14, 0, 14]);
104+
});
28105

29-
const trimmedFragment = trimListItems(fragment);
106+
it('identifies content inside an image node', () => {
107+
const fragment = doc(p(), p(img({src: 'blob:test.jpg', alt: 'test.jpg'})), p()).content;
108+
expect(findNotEmptyContentPosses(fragment)).toEqual([2, 5, 0, 7]);
109+
});
110+
111+
it('handles a document with only empty elements', () => {
112+
const fragment = doc(p(), ul(li(p(''))), ol(li(p('')))).content;
113+
expect(findNotEmptyContentPosses(fragment)).toEqual([-1, -1, 0, 14]);
114+
});
115+
116+
it('detects non-empty text after an empty section', () => {
117+
const fragment = doc(p(), p(), p('Content')).content;
118+
expect(findNotEmptyContentPosses(fragment)).toEqual([4, 13, 0, 13]);
119+
});
120+
121+
it('detects non-empty content after multiple nested empty lists', () => {
122+
const fragment = doc(ul(li(p(''), ul(li(p(''))))), ul(li(p(''))), p('Final')).content;
123+
expect(findNotEmptyContentPosses(fragment)).toEqual([18, 25, 0, 25]);
124+
});
125+
});
126+
127+
describe('trimContent', () => {
128+
it('removes empty list items at the start and end of a bullet list', () => {
129+
const fragment = doc(ul(li(), li(p('11')), li(p('22')), li())).content;
130+
131+
const trimmedFragment = trimContent(fragment);
30132
expect(schema.nodes.doc.create(null, trimmedFragment)).toMatchNode(
31-
doc(bulletList(listItem(p('11')), listItem(p('22')))),
133+
doc(ul(li(p('11')), li(p('22')))),
32134
);
33135
});
34136

35137
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;
138+
const fragment = doc(ol(li(), li(p('11')), li(p('22')), li())).content;
39139

40-
const trimmedFragment = trimListItems(fragment);
140+
const trimmedFragment = trimContent(fragment);
41141
expect(schema.nodes.doc.create(null, trimmedFragment)).toMatchNode(
42-
doc(orderedList(listItem(p('11')), listItem(p('22')))),
142+
doc(ol(li(p('11')), li(p('22')))),
43143
);
44144
});
45145

46146
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;
147+
const fragment = doc(ul(li(), li(p('11')), li(), li(p('22')), li())).content;
50148

51-
const trimmedFragment = trimListItems(fragment);
149+
const trimmedFragment = trimContent(fragment);
52150
expect(schema.nodes.doc.create(null, trimmedFragment)).toMatchNode(
53-
doc(bulletList(listItem(p('11')), listItem(), listItem(p('22')))),
151+
doc(ul(li(p('11')), li(), li(p('22')))),
54152
);
55153
});
56154

57155
it('does not modify a list with no empty items', () => {
58-
const fragment = doc(bulletList(listItem(p('11')), listItem(p('22')))).content;
156+
const fragment = doc(ul(li(p('11')), li(p('22')))).content;
59157

60-
const trimmedFragment = trimListItems(fragment);
158+
const trimmedFragment = trimContent(fragment);
61159
expect(schema.nodes.doc.create(null, trimmedFragment)).toMatchNode(
62-
doc(bulletList(listItem(p('11')), listItem(p('22')))),
160+
doc(ul(li(p('11')), li(p('22')))),
63161
);
64162
});
65163

66164
it('trims a list to one empty list item if it contains only empty list items', () => {
67-
const fragment = doc(bulletList(listItem(), listItem(), listItem())).content;
165+
const fragment = doc(ul(li(), li(), li())).content;
68166

69-
const trimmedFragment = trimListItems(fragment);
70-
expect(schema.nodes.doc.create(null, trimmedFragment)).toMatchNode(
71-
doc(bulletList(listItem())),
72-
);
167+
const trimmedFragment = trimContent(fragment);
168+
expect(schema.nodes.doc.create(null, trimmedFragment)).toMatchNode(doc(ul()));
73169
});
74170

75171
it('correctly handles multiple lists in a single fragment', () => {
76172
const fragment = doc(
77-
bulletList(listItem(), listItem(p('11')), listItem(), listItem(p('22')), listItem()),
78-
orderedList(listItem(), listItem(p('A')), listItem(p('B')), listItem()),
173+
ul(li(), li(p('11')), li(), li(p('22')), li()),
174+
ol(li(), li(p('A')), li(p('B')), li()),
79175
).content;
80176

81-
const trimmedFragment = trimListItems(fragment);
177+
const trimmedFragment = trimContent(fragment);
82178
expect(schema.nodes.doc.create(null, trimmedFragment)).toMatchNode(
83-
doc(
84-
bulletList(listItem(p('11')), listItem(), listItem(p('22'))),
85-
orderedList(listItem(p('A')), listItem(p('B'))),
86-
),
179+
doc(ul(li(p('11')), li(), li(p('22')), li()), ol(li(), li(p('A')), li(p('B')))),
87180
);
88181
});
89182

90183
it('does not modify paragraphs and other nodes outside lists', () => {
91184
const fragment = doc(
92185
p('Some text'),
93-
bulletList(listItem(), listItem(p('11')), listItem(p('22')), listItem()),
186+
ul(li(), li(p('11')), li(p('22')), li()),
94187
p('More text'),
95188
).content;
96189

97-
const trimmedFragment = trimListItems(fragment);
190+
const trimmedFragment = trimContent(fragment);
98191
expect(schema.nodes.doc.create(null, trimmedFragment)).toMatchNode(
99-
doc(p('Some text'), bulletList(listItem(p('11')), listItem(p('22'))), p('More text')),
192+
doc(p('Some text'), ul(li(), li(p('11')), li(p('22')), li()), p('More text')),
100193
);
101194
});
102195
});

src/extensions/behavior/Clipboard/utils.ts

Lines changed: 36 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Fragment, type Node} from 'prosemirror-model';
1+
import type {Fragment} from 'prosemirror-model';
22

33
import {isListItemNode, isListNode} from 'src/extensions/markdown/Lists/utils';
44
import {isEmptyString} from 'src/utils';
@@ -62,59 +62,47 @@ export function extractTextContentFromHtml(html: string) {
6262
return null;
6363
}
6464

65-
function isListItemEmpty(node: Node): boolean {
66-
let isEmpty = true;
67-
node.content.forEach((child) => {
68-
if (!isEmptyString(child)) {
69-
isEmpty = false;
70-
}
71-
});
72-
return isEmpty;
73-
}
74-
75-
export function trimListItems(fragment: Fragment): Fragment {
76-
let modified = false;
77-
const newChildren: Node[] = [];
78-
79-
fragment.forEach((contentNode) => {
80-
let result = contentNode;
65+
export function findNotEmptyContentPosses(fragment: Fragment): [number, number, number, number] {
66+
let firstNodePos = -1;
67+
let lastNodePos = -1;
68+
let firstNotEmptyNodePos = -1;
69+
let lastNotEmptyNodePos = -1;
8170

82-
if (isListNode(contentNode)) {
83-
const itemsArray: Node[] = Array.from(contentNode.content.content);
84-
if (itemsArray.length === 0) {
85-
return;
86-
}
71+
fragment.forEach((contentNode, offset) => {
72+
if (firstNodePos === -1) {
73+
firstNodePos = offset;
74+
}
75+
lastNodePos = offset + contentNode.nodeSize;
8776

88-
const initialStart = 0;
89-
const initialEnd = itemsArray.length - 1;
90-
let start = initialStart;
91-
let end = initialEnd;
92-
93-
while (start <= end) {
94-
if (isListItemNode(itemsArray[start])) {
95-
if (!isListItemEmpty(itemsArray[start])) {
96-
break;
97-
}
98-
start++;
77+
if (!isEmptyString(contentNode)) {
78+
if (isListNode(contentNode) || isListItemNode(contentNode)) {
79+
const [start, end] = findNotEmptyContentPosses(contentNode.content);
80+
if (firstNotEmptyNodePos === -1 && start !== -1) {
81+
firstNotEmptyNodePos = offset + start;
9982
}
100-
101-
if (start <= end && isListItemNode(itemsArray[end])) {
102-
if (!isListItemEmpty(itemsArray[end])) {
103-
break;
104-
}
105-
end--;
83+
if (end !== -1) {
84+
lastNotEmptyNodePos = offset + end;
10685
}
107-
}
108-
109-
if (start !== initialStart || end !== initialEnd) {
110-
const pos = start > end ? [0, 1] : [start, end + 1];
111-
result = contentNode.copy(Fragment.fromArray(itemsArray.slice(...pos)));
112-
modified = true;
86+
} else {
87+
if (firstNotEmptyNodePos === -1) {
88+
firstNotEmptyNodePos = offset;
89+
}
90+
lastNotEmptyNodePos = offset + contentNode.nodeSize;
11391
}
11492
}
115-
116-
newChildren.push(result);
11793
});
11894

119-
return modified ? Fragment.fromArray(newChildren) : fragment;
95+
return [firstNotEmptyNodePos, lastNotEmptyNodePos, firstNodePos, lastNodePos];
96+
}
97+
98+
export function trimContent(fragment: Fragment): Fragment {
99+
const [notEmptyStart, notEmptyEnd, start, end] = findNotEmptyContentPosses(fragment);
100+
101+
if (notEmptyStart === start && notEmptyEnd === end) {
102+
return fragment;
103+
} else if (notEmptyStart === -1 && notEmptyEnd === -1) {
104+
return fragment.cut(0, 1);
105+
}
106+
107+
return fragment.cut(notEmptyStart + 1, notEmptyEnd + 1);
120108
}

src/utils/nodes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export function getLastChildOfNode(node: Node | Fragment): NodeChild {
6262
}
6363

6464
export const isEmptyString = (node: Node) => {
65-
if (!node.content.size) {
65+
if (node.type.name === 'paragraph' && !node.content.size) {
6666
return true;
6767
}
6868

0 commit comments

Comments
 (0)