Skip to content

Commit da0544e

Browse files
authored
fix(Clipboard): copying no longer includes unrelated ancestor nodes (#1020)
1 parent fe7d54e commit da0544e

3 files changed

Lines changed: 267 additions & 13 deletions

File tree

packages/editor/src/extensions/behavior/Clipboard/clipboard.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {isNodeSelection, isTextSelection, isWholeSelection} from 'src/utils/sele
1010
import {BaseNode, pType} from '../../base/BaseSchema';
1111

1212
import {isInsideCode} from './code';
13+
import {getSelectionContent} from './selection-content';
1314
import {trimTextSelection} from './trim-selection';
1415
import {DataTransferType, extractTextContentFromHtml, isIosSafariShare, trimContent} from './utils';
1516

@@ -348,19 +349,6 @@ function createFragmentFromInlineSelection(state: EditorState, sel: Selection) {
348349
return Fragment.from(sel.$from.parent.copy(inlineSlice.content));
349350
}
350351

351-
/**
352-
* Like `selection.content()`, but smarter.
353-
* Copy a structure of complex nodes,
354-
* e.g. if select part of cut title it creates slice with yfm-cut –> yfm-cut-title -> selected text
355-
* it works well with simple nodes, but to handle cases as described above, custom logic needed
356-
*/
357-
function getSelectionContent(sel: Selection) {
358-
const sharedNodeType = getSharedDepthNode(sel).type;
359-
const sharedNodeComplex = sharedNodeType.spec.complex;
360-
const includeParents = sharedNodeComplex && sharedNodeComplex !== 'leaf';
361-
return sel.$from.doc.slice(sel.$from.pos, sel.to, includeParents);
362-
}
363-
364352
function getSharedDepthNode({$from, $to}: {$from: ResolvedPos; $to: ResolvedPos}): Node {
365353
return $from.node($from.sharedDepth($to.pos));
366354
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import type {Node} from '#pm/model';
2+
import {TextSelection} from '#pm/state';
3+
import {builders} from '#pm/test-builder';
4+
5+
import {ExtensionsManager} from '../../../core';
6+
import {BaseNode, BaseSchemaSpecs} from '../../base/BaseSchema/BaseSchemaSpecs';
7+
import {BlockquoteSpecs, blockquoteNodeName} from '../../markdown/Blockquote/BlockquoteSpecs';
8+
import {ListNode, ListsSpecs} from '../../markdown/Lists/ListsSpecs';
9+
import {TableNode, TableSpecs} from '../../markdown/Table/TableSpecs';
10+
import {YfmTableNode, YfmTableSpecs} from '../../yfm/YfmTable/YfmTableSpecs';
11+
12+
import {getSelectionContent} from './selection-content';
13+
14+
function buildDeps() {
15+
return new ExtensionsManager({
16+
extensions: (builder) => {
17+
builder
18+
.use(BaseSchemaSpecs, {})
19+
.use(BlockquoteSpecs)
20+
.use(ListsSpecs)
21+
.use(TableSpecs)
22+
.use(YfmTableSpecs, {});
23+
},
24+
}).buildDeps();
25+
}
26+
27+
const {schema} = buildDeps();
28+
29+
const {
30+
doc,
31+
p,
32+
bquote,
33+
ul,
34+
ol,
35+
li,
36+
table,
37+
thead,
38+
tbody,
39+
tr,
40+
th,
41+
td,
42+
yfmTable,
43+
yfmTbody,
44+
yfmTr,
45+
yfmTd,
46+
} = builders<
47+
| 'doc'
48+
| 'p'
49+
| 'bquote'
50+
| 'ul'
51+
| 'ol'
52+
| 'li'
53+
| 'table'
54+
| 'thead'
55+
| 'tbody'
56+
| 'tr'
57+
| 'th'
58+
| 'td'
59+
| 'yfmTable'
60+
| 'yfmTbody'
61+
| 'yfmTr'
62+
| 'yfmTd'
63+
>(schema, {
64+
doc: {nodeType: BaseNode.Doc},
65+
p: {nodeType: BaseNode.Paragraph},
66+
bquote: {nodeType: blockquoteNodeName},
67+
ul: {nodeType: ListNode.BulletList},
68+
ol: {nodeType: ListNode.OrderedList},
69+
li: {nodeType: ListNode.ListItem},
70+
table: {nodeType: TableNode.Table},
71+
thead: {nodeType: TableNode.Head},
72+
tbody: {nodeType: TableNode.Body},
73+
tr: {nodeType: TableNode.Row},
74+
th: {nodeType: TableNode.HeaderCell},
75+
td: {nodeType: TableNode.DataCell},
76+
yfmTable: {nodeType: YfmTableNode.Table},
77+
yfmTbody: {nodeType: YfmTableNode.Body},
78+
yfmTr: {nodeType: YfmTableNode.Row},
79+
yfmTd: {nodeType: YfmTableNode.Cell},
80+
});
81+
82+
function createSelection(node: Node, from: number, to: number) {
83+
return TextSelection.create(node, from, to);
84+
}
85+
86+
describe('getSelectionContent', () => {
87+
describe('simple list (no outer container)', () => {
88+
it('should include bullet_list wrapper when selecting across list items', () => {
89+
// doc > ul > li > p > "text"
90+
const node = doc(ul(li(p('item1')), li(p('item2')), li(p('item3'))));
91+
// Select from start of 'item1' to end of 'item3'
92+
const from = 3; // start of 'item1'
93+
const to = 26; // end of 'item3'
94+
const sel = createSelection(node, from, to);
95+
const slice = getSelectionContent(sel);
96+
97+
// Slice should contain the bullet_list as a wrapper
98+
expect(slice.content.firstChild?.type.name).toBe(ListNode.BulletList);
99+
expect(slice.content.childCount).toBe(1);
100+
expect(slice.content.firstChild).toMatchNode(
101+
ul(li(p('item1')), li(p('item2')), li(p('item3'))),
102+
);
103+
});
104+
105+
it('should include ordered_list wrapper when selecting across list items', () => {
106+
const node = doc(ol(li(p('item1')), li(p('item2'))));
107+
const from = 3; // start of 'item1'
108+
const to = 17; // end of 'item2'
109+
const sel = createSelection(node, from, to);
110+
const slice = getSelectionContent(sel);
111+
112+
expect(slice.content.firstChild?.type.name).toBe(ListNode.OrderedList);
113+
expect(slice.content.childCount).toBe(1);
114+
expect(slice.content.firstChild).toMatchNode(ol(li(p('item1')), li(p('item2'))));
115+
});
116+
});
117+
118+
describe('list inside blockquote', () => {
119+
it('should include bullet_list but NOT blockquote when selecting list items inside blockquote', () => {
120+
// doc > bquote > ul > li > p > "text"
121+
const node = doc(bquote(ul(li(p('item1')), li(p('item2')))));
122+
// Select from start of 'item1' to end of 'item2'
123+
const from = 4; // start of 'item1'
124+
const to = 18; // end of 'item2'
125+
const sel = createSelection(node, from, to);
126+
const slice = getSelectionContent(sel);
127+
128+
// Should contain bullet_list, NOT blockquote
129+
expect(slice.content.firstChild?.type.name).toBe(ListNode.BulletList);
130+
expect(slice.content.childCount).toBe(1);
131+
expect(slice.content.firstChild).toMatchNode(ul(li(p('item1')), li(p('item2'))));
132+
});
133+
134+
it('should include bullet_list but NOT blockquote when selecting partial list items', () => {
135+
const node = doc(bquote(ul(li(p('item1')), li(p('item2')), li(p('item3')))));
136+
// Select from middle of 'item1' to middle of 'item2'
137+
const from = 6; // middle of 'item1' (it|em1)
138+
const to = 15; // middle of 'item2' (it|em2)
139+
const sel = createSelection(node, from, to);
140+
const slice = getSelectionContent(sel);
141+
142+
// Should contain bullet_list, NOT blockquote
143+
expect(slice.content.firstChild?.type.name).toBe(ListNode.BulletList);
144+
expect(slice.content.childCount).toBe(1);
145+
expect(slice.content.firstChild).toMatchNode(ul(li(p('em1')), li(p('it'))));
146+
});
147+
});
148+
149+
describe('selection inside table', () => {
150+
it('should return slice with only selected text, not table wrapper, when selecting within a single cell', () => {
151+
const node = doc(
152+
table(thead(tr(th('head1'), th('head2'))), tbody(tr(td('cell1'), td('cell2')))),
153+
);
154+
const from = 22; // start of 'cell1'
155+
const to = 25; // // middle of 'cell1' (cel|l1)
156+
const sel = createSelection(node, from, to);
157+
const slice = getSelectionContent(sel);
158+
159+
// Shared node is td (complex: 'leaf'), should return simple slice without table
160+
expect(slice.content.firstChild?.type.name).not.toBe(TableNode.Table);
161+
expect(slice.content.firstChild?.type.name).not.toBe(TableNode.DataCell);
162+
expect(slice.content.childCount).toBe(1);
163+
expect(slice.content.firstChild?.textContent).toBe('cel');
164+
});
165+
166+
it('should return slice with table wrapper when selecting across multiple cells', () => {
167+
const node = doc(
168+
table(thead(tr(th('head1'), th('head2'))), tbody(tr(td('cell1'), td('cell2')))),
169+
);
170+
const from = 22; // start of 'cell1'
171+
const to = 34; // // end of 'cell2'
172+
const sel = createSelection(node, from, to);
173+
const slice = getSelectionContent(sel);
174+
175+
// Shared node is tr (complex: 'inner'), should find table (complex: 'root')
176+
// and include it in the slice
177+
expect(slice.content.firstChild?.type.name).toBe(TableNode.Table);
178+
expect(slice.content.childCount).toBe(1);
179+
expect(slice.content.firstChild).toMatchNode(
180+
table(tbody(tr(td('cell1'), td('cell2')))),
181+
);
182+
});
183+
});
184+
185+
describe('list inside yfm table cell', () => {
186+
it('should include bullet_list but NOT yfm_table when selecting list items inside yfm table cell', () => {
187+
const node = doc(yfmTable(yfmTbody(yfmTr(yfmTd(ul(li(p('item1')), li(p('item2'))))))));
188+
189+
const from = 7; // start of 'item1'
190+
const to = 21; // end of 'item2'
191+
const sel = createSelection(node, from, to);
192+
const slice = getSelectionContent(sel);
193+
194+
// Should contain bullet_list, NOT yfm_table structure
195+
expect(slice.content.firstChild?.type.name).toBe(ListNode.BulletList);
196+
expect(slice.content.childCount).toBe(1);
197+
expect(slice.content.firstChild).toMatchNode(ul(li(p('item1')), li(p('item2'))));
198+
});
199+
});
200+
201+
describe('non-complex shared node', () => {
202+
it('should return simple slice without parent wrapping for plain paragraphs', () => {
203+
const node = doc(p('text1'), p('text2'));
204+
205+
const from = 1; // start of 'text1'
206+
const to = 13; // end of 'text2'
207+
const sel = createSelection(node, from, to);
208+
const slice = getSelectionContent(sel);
209+
210+
// Shared node is doc (not complex), should return a simple slice
211+
expect(slice.content.childCount).toBe(2);
212+
expect(slice.content.firstChild).toMatchNode(p('text1'));
213+
expect(slice.content.lastChild).toMatchNode(p('text2'));
214+
});
215+
216+
it('should return simple slice for selection inside blockquote paragraphs', () => {
217+
const node = doc(bquote(p('text1'), p('text2')));
218+
219+
const from = 2; // start of 'text1'
220+
const to = 14; // end of 'text2'
221+
const sel = createSelection(node, from, to);
222+
const slice = getSelectionContent(sel);
223+
224+
// Shared node is blockquote (not complex), should be simple slice
225+
expect(slice.content.childCount).toBe(2);
226+
expect(slice.content.firstChild).toMatchNode(p('text1'));
227+
expect(slice.content.lastChild).toMatchNode(p('text2'));
228+
});
229+
});
230+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type {Selection} from '#pm/state';
2+
3+
/**
4+
* @internal
5+
*
6+
* Like `selection.content()`, but smarter.
7+
* Copy a structure of complex nodes,
8+
* e.g. if select part of cut title it creates slice with yfm-cut –> yfm-cut-title -> selected text
9+
* it works well with simple nodes, but to handle cases as described above, custom logic needed
10+
*/
11+
export function getSelectionContent(sel: Selection) {
12+
const sharedDepth = sel.$from.sharedDepth(sel.$to.pos);
13+
const sharedNode = sel.$from.node(sharedDepth);
14+
const sharedNodeComplex = sharedNode.type.spec.complex;
15+
16+
if (sharedNodeComplex && sharedNodeComplex !== 'leaf') {
17+
// Find the nearest ancestor with complex='root' to avoid
18+
// wrapping copied content in outer containers (blockquote, table cell, etc.)
19+
let rootDepth = sharedDepth;
20+
for (let d = sharedDepth; d >= 0; d--) {
21+
if (sel.$from.node(d).type.spec.complex === 'root') {
22+
rootDepth = d;
23+
break;
24+
}
25+
}
26+
27+
// Slice from the parent of the root node so that the root node itself
28+
// (e.g. bullet_list) is included in the slice, but its outer containers are not.
29+
const parentDepth = Math.max(0, rootDepth - 1);
30+
const parentNode = sel.$from.node(parentDepth);
31+
const parentStart = sel.$from.start(parentDepth);
32+
return parentNode.slice(sel.$from.pos - parentStart, sel.$to.pos - parentStart, true);
33+
}
34+
35+
return sel.$from.doc.slice(sel.$from.pos, sel.to, false);
36+
}

0 commit comments

Comments
 (0)