@use-kona/editor 0.1.32 → 0.1.34
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/dist/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.js +15 -0
- package/dist/plugins/DnDPlugin/DnDPlugin.js +33 -26
- package/package.json +1 -1
- package/src/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.spec.tsx +139 -1
- package/src/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.tsx +29 -0
- package/src/plugins/DnDPlugin/DnDPlugin.spec.ts +349 -85
- package/src/plugins/DnDPlugin/DnDPlugin.tsx +60 -45
|
@@ -11,6 +11,21 @@ class CollapsibleBlocksPlugin {
|
|
|
11
11
|
}
|
|
12
12
|
init(editor) {
|
|
13
13
|
sectionTypesByEditor.set(editor, this.types);
|
|
14
|
+
const { insertBreak } = editor;
|
|
15
|
+
editor.insertBreak = ()=>{
|
|
16
|
+
const { selection } = editor;
|
|
17
|
+
const boundary = selection && Range.isCollapsed(selection) ? Editor.above(editor, {
|
|
18
|
+
match: (node, path)=>1 === path.length && Element.isElement(node) && this.types.includes(node.type) && true === node.collapsed
|
|
19
|
+
}) : void 0;
|
|
20
|
+
Editor.withoutNormalizing(editor, ()=>{
|
|
21
|
+
if (boundary) Transforms.setNodes(editor, {
|
|
22
|
+
collapsed: false
|
|
23
|
+
}, {
|
|
24
|
+
at: boundary[1]
|
|
25
|
+
});
|
|
26
|
+
insertBreak();
|
|
27
|
+
});
|
|
28
|
+
};
|
|
14
29
|
return editor;
|
|
15
30
|
}
|
|
16
31
|
renderBlock = (props)=>{
|
|
@@ -187,34 +187,41 @@ const moveNode = (editor, targetNodePath, nodeIds, position, sourcePath, section
|
|
|
187
187
|
const dropPath = getDropPath(editor, targetNodePath, position);
|
|
188
188
|
if (!dropPath) return;
|
|
189
189
|
if (!isValidInsertPath(editor, dropPath)) return;
|
|
190
|
-
|
|
190
|
+
const explicitPaths = sectionPaths?.length ? sectionPaths : 0 === nodeIds.length && sourcePath ? [
|
|
191
|
+
sourcePath
|
|
192
|
+
] : void 0;
|
|
193
|
+
const explicitNodes = explicitPaths && new Set(explicitPaths.filter((path)=>Editor.hasPath(editor, path)).map((path)=>Editor.node(editor, path)[0]));
|
|
194
|
+
const paths = Array.from(Editor.nodes(editor, {
|
|
195
|
+
at: [],
|
|
196
|
+
match: (n)=>{
|
|
197
|
+
if (explicitNodes) return explicitNodes.has(n);
|
|
198
|
+
return Element.isElement(n) && Editor.isBlock(editor, n) && 'string' == typeof n.nodeId && nodeIds.includes(n.nodeId);
|
|
199
|
+
},
|
|
200
|
+
mode: 'highest'
|
|
201
|
+
}), ([, path])=>path);
|
|
202
|
+
if (paths.some((path)=>Path.equals(path, targetNodePath) || Path.isAncestor(path, targetNodePath))) return;
|
|
203
|
+
const targetRef = Editor.pathRef(editor, targetNodePath);
|
|
204
|
+
const sourceRefs = paths.map((path)=>Editor.pathRef(editor, path));
|
|
191
205
|
Editor.withoutNormalizing(editor, ()=>{
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
206
|
+
try {
|
|
207
|
+
const orderedRefs = 'top' === position ? sourceRefs : [
|
|
208
|
+
...sourceRefs
|
|
209
|
+
].reverse();
|
|
210
|
+
for (const sourceRef of orderedRefs){
|
|
211
|
+
const from = sourceRef.current;
|
|
212
|
+
const target = targetRef.current;
|
|
213
|
+
if (!from || !target) continue;
|
|
214
|
+
let to = 'top' === position ? target : Path.next(target);
|
|
215
|
+
if (Path.isSibling(from, to) && Path.isBefore(from, to)) to = Path.previous(to);
|
|
216
|
+
Transforms.moveNodes(editor, {
|
|
217
|
+
at: from,
|
|
218
|
+
to
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
} finally{
|
|
222
|
+
targetRef.unref();
|
|
223
|
+
for (const ref of sourceRefs)ref.unref();
|
|
201
224
|
}
|
|
202
|
-
if (0 === nodeIds.length && sourcePath && Editor.hasPath(editor, sourcePath)) return void Transforms.moveNodes(editor, {
|
|
203
|
-
at: sourcePath,
|
|
204
|
-
to: dropPath
|
|
205
|
-
});
|
|
206
|
-
Transforms.moveNodes(editor, {
|
|
207
|
-
at: [],
|
|
208
|
-
match: (n)=>{
|
|
209
|
-
if (!Editor.isBlock(editor, n)) return false;
|
|
210
|
-
if (Editor.isEditor(n)) return false;
|
|
211
|
-
const node = n;
|
|
212
|
-
const shouldMove = 'string' == typeof node.nodeId && nodeIds.includes(node.nodeId);
|
|
213
|
-
return shouldMove;
|
|
214
|
-
},
|
|
215
|
-
to: dropPath,
|
|
216
|
-
mode: 'highest'
|
|
217
|
-
});
|
|
218
225
|
});
|
|
219
226
|
Editor.normalize(editor);
|
|
220
227
|
};
|
package/package.json
CHANGED
|
@@ -9,11 +9,13 @@ import {
|
|
|
9
9
|
waitFor,
|
|
10
10
|
} from '@testing-library/react';
|
|
11
11
|
import React from 'react';
|
|
12
|
-
import {
|
|
12
|
+
import { Editor, Transforms } from 'slate';
|
|
13
13
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import type { CustomElement } from '../../../types';
|
|
14
15
|
import { KonaEditor } from '../../editor';
|
|
15
16
|
import type { IPlugin } from '../../types';
|
|
16
17
|
import { BasicFormattingPlugin } from '../BasicFormattingPlugin';
|
|
18
|
+
import { BreaksPlugin } from '../BreaksPlugin';
|
|
17
19
|
import { HeadingsPlugin } from '../HeadingsPlugin';
|
|
18
20
|
import { CollapsibleBlocksPlugin } from './CollapsibleBlocksPlugin';
|
|
19
21
|
|
|
@@ -214,3 +216,139 @@ describe('CollapsibleBlocksPlugin', () => {
|
|
|
214
216
|
});
|
|
215
217
|
});
|
|
216
218
|
});
|
|
219
|
+
|
|
220
|
+
describe('Enter in collapsed sections', () => {
|
|
221
|
+
const headingTypes = [
|
|
222
|
+
HeadingsPlugin.HeadingLevel1,
|
|
223
|
+
HeadingsPlugin.HeadingLevel2,
|
|
224
|
+
HeadingsPlugin.HeadingLevel3,
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
const renderEditor = (value: CustomElement[]) => {
|
|
228
|
+
let editor: Editor | undefined;
|
|
229
|
+
const capturePlugin: IPlugin = {
|
|
230
|
+
init(value) {
|
|
231
|
+
editor = value;
|
|
232
|
+
return value;
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
const { container, unmount } = render(
|
|
236
|
+
<KonaEditor
|
|
237
|
+
initialValue={value}
|
|
238
|
+
plugins={[
|
|
239
|
+
...plugins(),
|
|
240
|
+
new BreaksPlugin({ breakNodes: headingTypes }),
|
|
241
|
+
capturePlugin,
|
|
242
|
+
]}
|
|
243
|
+
onChange={() => {}}
|
|
244
|
+
/>,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
if (!editor) throw new Error('Expected editor to be initialized');
|
|
248
|
+
|
|
249
|
+
return { editor, container, cleanup: unmount };
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
it.each(headingTypes)(
|
|
253
|
+
'expands collapsed %s when Enter creates a paragraph after it',
|
|
254
|
+
async (type) => {
|
|
255
|
+
const { editor, container, cleanup } = renderEditor([
|
|
256
|
+
{
|
|
257
|
+
type,
|
|
258
|
+
collapsed: true,
|
|
259
|
+
children: [{ text: 'Heading' }],
|
|
260
|
+
},
|
|
261
|
+
{ type: 'paragraph', children: [{ text: 'Existing content' }] },
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
expect(container.querySelector('[hidden]')).not.toBeNull();
|
|
266
|
+
|
|
267
|
+
await act(async () => {
|
|
268
|
+
Transforms.select(editor, Editor.end(editor, [0]));
|
|
269
|
+
editor.insertBreak();
|
|
270
|
+
editor.insertText('New content');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(editor.children[0]).toMatchObject({ collapsed: false });
|
|
274
|
+
expect(editor.children[1]).toMatchObject({
|
|
275
|
+
type: 'paragraph',
|
|
276
|
+
children: [{ text: 'New content' }],
|
|
277
|
+
});
|
|
278
|
+
expect(container.querySelector('[hidden]')).toBeNull();
|
|
279
|
+
expect(container.textContent).toContain('New content');
|
|
280
|
+
expect(
|
|
281
|
+
container
|
|
282
|
+
.querySelector('[aria-label="Collapse section"]')
|
|
283
|
+
?.getAttribute('aria-expanded'),
|
|
284
|
+
).toBe('true');
|
|
285
|
+
} finally {
|
|
286
|
+
cleanup();
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
it('keeps a section collapsed when Enter inserts a paragraph before its heading', async () => {
|
|
292
|
+
const { editor, container, cleanup } = renderEditor([
|
|
293
|
+
{
|
|
294
|
+
type: HeadingsPlugin.HeadingLevel1,
|
|
295
|
+
collapsed: true,
|
|
296
|
+
children: [{ text: 'Heading' }],
|
|
297
|
+
},
|
|
298
|
+
{ type: 'paragraph', children: [{ text: 'Existing content' }] },
|
|
299
|
+
]);
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
await act(async () => {
|
|
303
|
+
Transforms.select(editor, Editor.start(editor, [0]));
|
|
304
|
+
editor.insertBreak();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(editor.children[0]).toMatchObject({ type: 'paragraph' });
|
|
308
|
+
expect(editor.children[1]).toMatchObject({ collapsed: true });
|
|
309
|
+
expect(container.querySelectorAll('[hidden]')).toHaveLength(1);
|
|
310
|
+
} finally {
|
|
311
|
+
cleanup();
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('expands the edited section while preserving nested and adjacent collapsed sections', async () => {
|
|
316
|
+
const { editor, container, cleanup } = renderEditor([
|
|
317
|
+
{
|
|
318
|
+
type: HeadingsPlugin.HeadingLevel1,
|
|
319
|
+
collapsed: true,
|
|
320
|
+
children: [{ text: 'Heading' }],
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
type: HeadingsPlugin.HeadingLevel2,
|
|
324
|
+
collapsed: true,
|
|
325
|
+
children: [{ text: 'Nested heading' }],
|
|
326
|
+
},
|
|
327
|
+
{ type: 'paragraph', children: [{ text: 'Nested content' }] },
|
|
328
|
+
{
|
|
329
|
+
type: HeadingsPlugin.HeadingLevel1,
|
|
330
|
+
collapsed: true,
|
|
331
|
+
children: [{ text: 'Adjacent heading' }],
|
|
332
|
+
},
|
|
333
|
+
{ type: 'paragraph', children: [{ text: 'Adjacent content' }] },
|
|
334
|
+
]);
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
await act(async () => {
|
|
338
|
+
Transforms.select(editor, Editor.end(editor, [0]));
|
|
339
|
+
editor.insertBreak();
|
|
340
|
+
editor.insertText('New content');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(editor.children[0]).toMatchObject({ collapsed: false });
|
|
344
|
+
expect(editor.children[2]).toMatchObject({ collapsed: true });
|
|
345
|
+
expect(editor.children[4]).toMatchObject({ collapsed: true });
|
|
346
|
+
expect(container.querySelectorAll('[hidden]')).toHaveLength(2);
|
|
347
|
+
expect(
|
|
348
|
+
container.querySelectorAll('[aria-label="Expand section"]'),
|
|
349
|
+
).toHaveLength(2);
|
|
350
|
+
} finally {
|
|
351
|
+
cleanup();
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
});
|
|
@@ -18,6 +18,35 @@ export class CollapsibleBlocksPlugin implements IPlugin {
|
|
|
18
18
|
|
|
19
19
|
init(editor: Editor) {
|
|
20
20
|
sectionTypesByEditor.set(editor, this.types);
|
|
21
|
+
|
|
22
|
+
const { insertBreak } = editor;
|
|
23
|
+
|
|
24
|
+
editor.insertBreak = () => {
|
|
25
|
+
const { selection } = editor;
|
|
26
|
+
const boundary =
|
|
27
|
+
selection && Range.isCollapsed(selection)
|
|
28
|
+
? Editor.above<CollapsibleElement>(editor, {
|
|
29
|
+
match: (node, path) =>
|
|
30
|
+
path.length === 1 &&
|
|
31
|
+
Element.isElement(node) &&
|
|
32
|
+
this.types.includes(node.type) &&
|
|
33
|
+
node.collapsed === true,
|
|
34
|
+
})
|
|
35
|
+
: undefined;
|
|
36
|
+
|
|
37
|
+
Editor.withoutNormalizing(editor, () => {
|
|
38
|
+
if (boundary) {
|
|
39
|
+
Transforms.setNodes<CollapsibleElement>(
|
|
40
|
+
editor,
|
|
41
|
+
{ collapsed: false },
|
|
42
|
+
{ at: boundary[1] },
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
insertBreak();
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
21
50
|
return editor;
|
|
22
51
|
}
|
|
23
52
|
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
3
|
import '@testing-library/jest-dom/vitest';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
act,
|
|
6
|
+
fireEvent,
|
|
7
|
+
render,
|
|
8
|
+
waitFor,
|
|
9
|
+
within,
|
|
10
|
+
} from '@testing-library/react';
|
|
5
11
|
import { createDragDropManager, type DragDropManager } from 'dnd-core';
|
|
6
12
|
import React from 'react';
|
|
7
13
|
import { DndProvider } from 'react-dnd';
|
|
8
14
|
import type { Editor } from 'slate';
|
|
9
|
-
import { createEditor } from 'slate';
|
|
15
|
+
import { createEditor, Node, Editor as SlateEditor } from 'slate';
|
|
10
16
|
import { describe, expect, it, vi } from 'vitest';
|
|
11
17
|
import { KonaEditor } from '../../editor';
|
|
12
18
|
import type { IPlugin } from '../../types';
|
|
13
19
|
import { CollapsibleBlocksPlugin } from '../CollapsibleBlocksPlugin';
|
|
14
20
|
import { HeadingsPlugin } from '../HeadingsPlugin';
|
|
21
|
+
import { ListsPlugin } from '../ListsPlugin';
|
|
15
22
|
import { DnDPlugin, moveNode } from './DnDPlugin';
|
|
16
23
|
|
|
17
24
|
Object.assign(globalThis, { React });
|
|
@@ -61,6 +68,181 @@ const sectionEditor = () => {
|
|
|
61
68
|
};
|
|
62
69
|
|
|
63
70
|
describe('moveNode', () => {
|
|
71
|
+
const paragraph = (text: string) => ({
|
|
72
|
+
type: 'paragraph',
|
|
73
|
+
nodeId: text,
|
|
74
|
+
children: [{ text }],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const directions = [
|
|
78
|
+
{ direction: 'down', initial: ['A', 'X', 'B', 'Y'], source: 0, target: 2 },
|
|
79
|
+
{ direction: 'up', initial: ['X', 'B', 'Y', 'A'], source: 3, target: 1 },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
describe.each(['top', 'bottom'] as const)('drop at %s', (position) => {
|
|
83
|
+
it.each(directions)(
|
|
84
|
+
'moves one block $direction using IDs or a source path',
|
|
85
|
+
({ initial, source, target }) => {
|
|
86
|
+
for (const useIds of [true, false]) {
|
|
87
|
+
const editor = createEditor();
|
|
88
|
+
editor.children = initial.map(paragraph);
|
|
89
|
+
|
|
90
|
+
moveNode(editor, [target], useIds ? ['A'] : [], position, [source]);
|
|
91
|
+
|
|
92
|
+
expect(editor.children.map(Node.string)).toEqual(
|
|
93
|
+
position === 'top' ? ['X', 'A', 'B', 'Y'] : ['X', 'B', 'A', 'Y'],
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
it.each([
|
|
100
|
+
{ name: 'down', initial: ['A', 'C', 'X', 'B', 'Y'] },
|
|
101
|
+
{ name: 'up', initial: ['X', 'B', 'Y', 'A', 'C'] },
|
|
102
|
+
{ name: 'noncontiguous', initial: ['A', 'X', 'C', 'B', 'Y'] },
|
|
103
|
+
{ name: 'across the target', initial: ['A', 'X', 'B', 'Y', 'C'] },
|
|
104
|
+
])('moves a selection $name in document order', ({ initial }) => {
|
|
105
|
+
const editor = createEditor();
|
|
106
|
+
editor.children = initial.map(paragraph);
|
|
107
|
+
|
|
108
|
+
moveNode(editor, [initial.indexOf('B')], ['C', 'A'], position);
|
|
109
|
+
|
|
110
|
+
expect(editor.children.map(Node.string)).toEqual(
|
|
111
|
+
position === 'top'
|
|
112
|
+
? ['X', 'A', 'C', 'B', 'Y']
|
|
113
|
+
: ['X', 'B', 'A', 'C', 'Y'],
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it.each([true, false])(
|
|
118
|
+
'moves a section in both directions (collapsed=%s)',
|
|
119
|
+
(collapsed) => {
|
|
120
|
+
for (const direction of ['down', 'up']) {
|
|
121
|
+
const editor = sectionEditor();
|
|
122
|
+
const section = editor.children.slice(0, 4);
|
|
123
|
+
Object.assign(section[0], { collapsed });
|
|
124
|
+
const surrounding = ['X', 'B', 'Y'].map(paragraph);
|
|
125
|
+
editor.children =
|
|
126
|
+
direction === 'down'
|
|
127
|
+
? [...section, ...surrounding]
|
|
128
|
+
: [...surrounding, ...section];
|
|
129
|
+
const start = direction === 'down' ? 0 : 3;
|
|
130
|
+
|
|
131
|
+
moveNode(
|
|
132
|
+
editor,
|
|
133
|
+
[direction === 'down' ? 5 : 1],
|
|
134
|
+
[],
|
|
135
|
+
position,
|
|
136
|
+
undefined,
|
|
137
|
+
[0, 1, 2, 3].map((index) => [start + index]),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(editor.children).toEqual(
|
|
141
|
+
position === 'top'
|
|
142
|
+
? [surrounding[0], ...section, surrounding[1], surrounding[2]]
|
|
143
|
+
: [surrounding[0], surrounding[1], ...section, surrounding[2]],
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
it.each(directions)(
|
|
150
|
+
'reorders items $direction within a nested list',
|
|
151
|
+
({ initial, source, target }) => {
|
|
152
|
+
const editor = new ListsPlugin().init(createEditor());
|
|
153
|
+
const items = initial.map((text) => ({
|
|
154
|
+
...paragraph(text),
|
|
155
|
+
type: 'li',
|
|
156
|
+
}));
|
|
157
|
+
editor.children = [
|
|
158
|
+
{
|
|
159
|
+
type: 'ul',
|
|
160
|
+
children: [
|
|
161
|
+
{ type: 'li', children: [{ text: 'Parent' }] },
|
|
162
|
+
{ type: 'ul', children: items },
|
|
163
|
+
{ type: 'li', children: [{ text: 'Tail' }] },
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
moveNode(editor, [0, 1, target], ['A'], position, [0, 1, source]);
|
|
169
|
+
|
|
170
|
+
expect(
|
|
171
|
+
Array.from(Node.children(editor, [0, 1]), ([node]) =>
|
|
172
|
+
Node.string(node),
|
|
173
|
+
),
|
|
174
|
+
).toEqual(
|
|
175
|
+
position === 'top' ? ['X', 'A', 'B', 'Y'] : ['X', 'B', 'A', 'Y'],
|
|
176
|
+
);
|
|
177
|
+
expect(Node.string(Node.get(editor, [0, 0]))).toBe('Parent');
|
|
178
|
+
expect(Node.string(Node.get(editor, [0, 2]))).toBe('Tail');
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
it.each(['into', 'out of'])(
|
|
183
|
+
'moves an item %s a nested list',
|
|
184
|
+
(direction) => {
|
|
185
|
+
const editor = new ListsPlugin().init(createEditor());
|
|
186
|
+
const item = (text: string) => ({ ...paragraph(text), type: 'li' });
|
|
187
|
+
editor.children = [
|
|
188
|
+
{
|
|
189
|
+
type: 'ul',
|
|
190
|
+
children: [
|
|
191
|
+
item('A'),
|
|
192
|
+
{ type: 'ul', children: [item('B'), item('C')] },
|
|
193
|
+
item('D'),
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
if (direction === 'into') {
|
|
199
|
+
moveNode(editor, [0, 1, 0], ['A'], position);
|
|
200
|
+
expect(editor.children).toEqual([
|
|
201
|
+
{
|
|
202
|
+
type: 'ul',
|
|
203
|
+
children: [
|
|
204
|
+
{
|
|
205
|
+
type: 'ul',
|
|
206
|
+
children: (position === 'top'
|
|
207
|
+
? ['A', 'B', 'C']
|
|
208
|
+
: ['B', 'A', 'C']
|
|
209
|
+
).map(item),
|
|
210
|
+
},
|
|
211
|
+
item('D'),
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
]);
|
|
215
|
+
} else {
|
|
216
|
+
moveNode(editor, [0, 2], ['B'], position);
|
|
217
|
+
const nested = { type: 'ul', children: [item('C')] };
|
|
218
|
+
expect(editor.children).toEqual([
|
|
219
|
+
{
|
|
220
|
+
type: 'ul',
|
|
221
|
+
children:
|
|
222
|
+
position === 'top'
|
|
223
|
+
? [item('A'), nested, item('B'), item('D')]
|
|
224
|
+
: [item('A'), nested, item('D'), item('B')],
|
|
225
|
+
},
|
|
226
|
+
]);
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it.each(['top', 'bottom'] as const)(
|
|
233
|
+
'does not move a selection onto itself at %s',
|
|
234
|
+
(position) => {
|
|
235
|
+
const editor = createEditor();
|
|
236
|
+
editor.children = ['A', 'B', 'C'].map(paragraph);
|
|
237
|
+
const original = structuredClone(editor.children);
|
|
238
|
+
|
|
239
|
+
moveNode(editor, [1], ['A', 'B'], position);
|
|
240
|
+
|
|
241
|
+
expect(editor.children).toEqual(original);
|
|
242
|
+
expect(SlateEditor.pathRefs(editor).size).toBe(0);
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
|
|
64
246
|
it('moves a list item before another list item', () => {
|
|
65
247
|
const editor = listEditor();
|
|
66
248
|
|
|
@@ -149,99 +331,181 @@ const getHandlerIds = (
|
|
|
149
331
|
};
|
|
150
332
|
|
|
151
333
|
describe('DnDPlugin', () => {
|
|
152
|
-
it(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
334
|
+
it.each([
|
|
335
|
+
{
|
|
336
|
+
selected: [],
|
|
337
|
+
direction: 'down',
|
|
338
|
+
source: 0,
|
|
339
|
+
target: 2,
|
|
340
|
+
y: 0,
|
|
341
|
+
expected: ['Second', 'First', 'Third', 'Fourth'],
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
selected: [],
|
|
345
|
+
direction: 'up',
|
|
346
|
+
source: 3,
|
|
347
|
+
target: 1,
|
|
348
|
+
y: 0,
|
|
349
|
+
expected: ['First', 'Fourth', 'Second', 'Third'],
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
selected: [],
|
|
353
|
+
direction: 'down',
|
|
354
|
+
source: 0,
|
|
355
|
+
target: 2,
|
|
356
|
+
y: 100,
|
|
357
|
+
expected: ['Second', 'Third', 'First', 'Fourth'],
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
selected: [],
|
|
361
|
+
direction: 'up',
|
|
362
|
+
source: 3,
|
|
363
|
+
target: 1,
|
|
364
|
+
y: 100,
|
|
365
|
+
expected: ['First', 'Second', 'Fourth', 'Third'],
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
selected: [0, 1],
|
|
369
|
+
direction: 'down',
|
|
370
|
+
source: 0,
|
|
371
|
+
target: 3,
|
|
372
|
+
y: 0,
|
|
373
|
+
expected: ['Third', 'First', 'Second', 'Fourth'],
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
selected: [2, 3],
|
|
377
|
+
direction: 'up',
|
|
378
|
+
source: 3,
|
|
379
|
+
target: 0,
|
|
380
|
+
y: 100,
|
|
381
|
+
expected: ['First', 'Third', 'Fourth', 'Second'],
|
|
382
|
+
},
|
|
383
|
+
])(
|
|
384
|
+
'reorders rendered paragraphs $direction at y=$y',
|
|
385
|
+
async ({ selected, source, target, y, expected }) => {
|
|
386
|
+
const manager = createDndManager();
|
|
387
|
+
const onChange = vi.fn();
|
|
388
|
+
let editor: Editor | undefined;
|
|
389
|
+
|
|
390
|
+
const dndPlugin = new DnDPlugin({
|
|
391
|
+
onDropFiles: () => {},
|
|
392
|
+
renderBlock: ({
|
|
393
|
+
dragRef,
|
|
394
|
+
dropRef,
|
|
395
|
+
previewRef,
|
|
396
|
+
props,
|
|
397
|
+
onToggleSelected,
|
|
398
|
+
}) =>
|
|
166
399
|
React.createElement(
|
|
167
|
-
'
|
|
168
|
-
{ contentEditable: false, ref: dragRef, type: 'button' },
|
|
169
|
-
'Drag',
|
|
170
|
-
),
|
|
171
|
-
props.children,
|
|
172
|
-
),
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
render(
|
|
176
|
-
React.createElement(
|
|
177
|
-
DndProvider,
|
|
178
|
-
{ manager },
|
|
179
|
-
React.createElement(KonaEditor, {
|
|
180
|
-
initialValue: [
|
|
181
|
-
{ type: 'paragraph', children: [{ text: 'First' }] },
|
|
182
|
-
{ type: 'paragraph', children: [{ text: 'Second' }] },
|
|
183
|
-
],
|
|
184
|
-
plugins: [
|
|
185
|
-
dndPlugin,
|
|
400
|
+
'div',
|
|
186
401
|
{
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
402
|
+
...props.attributes,
|
|
403
|
+
ref: (element) => dropRef(previewRef(element)),
|
|
404
|
+
},
|
|
405
|
+
React.createElement(
|
|
406
|
+
'button',
|
|
407
|
+
{ contentEditable: false, ref: dragRef, type: 'button' },
|
|
408
|
+
'Drag',
|
|
409
|
+
),
|
|
410
|
+
React.createElement(
|
|
411
|
+
'button',
|
|
412
|
+
{
|
|
413
|
+
type: 'button',
|
|
414
|
+
contentEditable: false,
|
|
415
|
+
onClick: onToggleSelected,
|
|
190
416
|
},
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
)
|
|
196
|
-
);
|
|
197
|
-
|
|
198
|
-
const originalRect = HTMLElement.prototype.getBoundingClientRect;
|
|
199
|
-
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
|
|
200
|
-
configurable: true,
|
|
201
|
-
value: () => ({ bottom: 100, top: 0 }),
|
|
202
|
-
});
|
|
417
|
+
'Select',
|
|
418
|
+
),
|
|
419
|
+
props.children,
|
|
420
|
+
),
|
|
421
|
+
});
|
|
203
422
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
423
|
+
const view = render(
|
|
424
|
+
React.createElement(
|
|
425
|
+
DndProvider,
|
|
426
|
+
{ manager },
|
|
427
|
+
React.createElement(KonaEditor, {
|
|
428
|
+
initialValue: [
|
|
429
|
+
{
|
|
430
|
+
type: 'paragraph',
|
|
431
|
+
nodeId: 'First',
|
|
432
|
+
children: [{ text: 'First' }],
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
type: 'paragraph',
|
|
436
|
+
nodeId: 'Second',
|
|
437
|
+
children: [{ text: 'Second' }],
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
type: 'paragraph',
|
|
441
|
+
nodeId: 'Third',
|
|
442
|
+
children: [{ text: 'Third' }],
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
type: 'paragraph',
|
|
446
|
+
nodeId: 'Fourth',
|
|
447
|
+
children: [{ text: 'Fourth' }],
|
|
448
|
+
},
|
|
449
|
+
],
|
|
450
|
+
plugins: [
|
|
451
|
+
dndPlugin,
|
|
452
|
+
{
|
|
453
|
+
init(value) {
|
|
454
|
+
editor = value;
|
|
455
|
+
return value;
|
|
456
|
+
},
|
|
457
|
+
} satisfies IPlugin,
|
|
458
|
+
],
|
|
459
|
+
onChange,
|
|
460
|
+
}),
|
|
461
|
+
),
|
|
462
|
+
);
|
|
207
463
|
|
|
208
|
-
|
|
209
|
-
|
|
464
|
+
for (const index of selected) {
|
|
465
|
+
fireEvent.click(within(view.container).getAllByText('Select')[index]);
|
|
466
|
+
}
|
|
210
467
|
|
|
211
|
-
|
|
212
|
-
manager.getActions().beginDrag([sourceIds[1]], {
|
|
213
|
-
clientOffset: { x: 0, y: 50 },
|
|
214
|
-
getSourceClientOffset: () => ({ x: 0, y: 0 }),
|
|
215
|
-
});
|
|
216
|
-
manager.getActions().publishDragSource();
|
|
217
|
-
manager
|
|
218
|
-
.getActions()
|
|
219
|
-
.hover([targetIds[0]], { clientOffset: { x: 0, y: 0 } });
|
|
220
|
-
expect(manager.getMonitor().canDropOnTarget(targetIds[0])).toBe(true);
|
|
221
|
-
manager.getActions().drop();
|
|
222
|
-
manager.getActions().endDrag();
|
|
223
|
-
});
|
|
224
|
-
} finally {
|
|
468
|
+
const originalRect = HTMLElement.prototype.getBoundingClientRect;
|
|
225
469
|
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
|
|
226
470
|
configurable: true,
|
|
227
|
-
value:
|
|
471
|
+
value: () => ({ bottom: 100, top: 0 }),
|
|
228
472
|
});
|
|
229
|
-
}
|
|
230
473
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
474
|
+
try {
|
|
475
|
+
const sourceIds = getHandlerIds(manager, 'dragSources');
|
|
476
|
+
const targetIds = getHandlerIds(manager, 'dropTargets');
|
|
477
|
+
|
|
478
|
+
expect(sourceIds).toHaveLength(4);
|
|
479
|
+
expect(targetIds).toHaveLength(4);
|
|
480
|
+
|
|
481
|
+
await act(async () => {
|
|
482
|
+
manager.getActions().beginDrag([sourceIds[source]], {
|
|
483
|
+
clientOffset: { x: 0, y: 50 },
|
|
484
|
+
getSourceClientOffset: () => ({ x: 0, y: 0 }),
|
|
485
|
+
});
|
|
486
|
+
manager.getActions().publishDragSource();
|
|
487
|
+
manager
|
|
488
|
+
.getActions()
|
|
489
|
+
.hover([targetIds[target]], { clientOffset: { x: 0, y } });
|
|
490
|
+
expect(manager.getMonitor().canDropOnTarget(targetIds[target])).toBe(
|
|
491
|
+
true,
|
|
492
|
+
);
|
|
493
|
+
manager.getActions().drop();
|
|
494
|
+
manager.getActions().endDrag();
|
|
495
|
+
});
|
|
496
|
+
} finally {
|
|
497
|
+
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
|
|
498
|
+
configurable: true,
|
|
499
|
+
value: originalRect,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
expect(editor?.children.map(Node.string)).toEqual(expected);
|
|
504
|
+
await waitFor(() =>
|
|
505
|
+
expect(onChange).toHaveBeenLastCalledWith(editor?.children),
|
|
506
|
+
);
|
|
507
|
+
},
|
|
508
|
+
);
|
|
245
509
|
|
|
246
510
|
it('moves every block in an expanded collapsible section', async () => {
|
|
247
511
|
const manager = createDndManager();
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
useReadOnly,
|
|
10
10
|
useSlate,
|
|
11
11
|
} from 'slate-react';
|
|
12
|
-
import type { CustomElement } from '../../../types';
|
|
13
12
|
import { useEditorContext } from '../../provider';
|
|
14
13
|
import type { IPlugin } from '../../types';
|
|
15
14
|
import {
|
|
@@ -329,55 +328,71 @@ export const moveNode = (
|
|
|
329
328
|
return;
|
|
330
329
|
}
|
|
331
330
|
|
|
332
|
-
|
|
331
|
+
const explicitPaths = sectionPaths?.length
|
|
332
|
+
? sectionPaths
|
|
333
|
+
: nodeIds.length === 0 && sourcePath
|
|
334
|
+
? [sourcePath]
|
|
335
|
+
: undefined;
|
|
336
|
+
const explicitNodes =
|
|
337
|
+
explicitPaths &&
|
|
338
|
+
new Set(
|
|
339
|
+
explicitPaths
|
|
340
|
+
.filter((path) => Editor.hasPath(editor, path))
|
|
341
|
+
.map((path) => Editor.node(editor, path)[0]),
|
|
342
|
+
);
|
|
343
|
+
const paths = Array.from(
|
|
344
|
+
Editor.nodes(editor, {
|
|
345
|
+
at: [],
|
|
346
|
+
match: (n) => {
|
|
347
|
+
if (explicitNodes) return explicitNodes.has(n);
|
|
348
|
+
return (
|
|
349
|
+
Element.isElement(n) &&
|
|
350
|
+
Editor.isBlock(editor, n) &&
|
|
351
|
+
typeof (n as NodeWithId).nodeId === 'string' &&
|
|
352
|
+
nodeIds.includes((n as NodeWithId).nodeId)
|
|
353
|
+
);
|
|
354
|
+
},
|
|
355
|
+
mode: 'highest',
|
|
356
|
+
}),
|
|
357
|
+
([, path]) => path,
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
// A selected target (or its ancestor) cannot also anchor the move.
|
|
361
|
+
if (
|
|
362
|
+
paths.some(
|
|
363
|
+
(path) =>
|
|
364
|
+
Path.equals(path, targetNodePath) ||
|
|
365
|
+
Path.isAncestor(path, targetNodePath),
|
|
366
|
+
)
|
|
367
|
+
) {
|
|
333
368
|
return;
|
|
334
369
|
}
|
|
335
370
|
|
|
336
|
-
Editor.
|
|
337
|
-
|
|
338
|
-
const sectionNodes = new Set(
|
|
339
|
-
sectionPaths
|
|
340
|
-
.filter((path) => Editor.hasPath(editor, path))
|
|
341
|
-
.map((path) => Editor.node(editor, path)[0]),
|
|
342
|
-
);
|
|
343
|
-
|
|
344
|
-
Transforms.moveNodes(editor, {
|
|
345
|
-
at: [],
|
|
346
|
-
match: (node) => sectionNodes.has(node),
|
|
347
|
-
to: dropPath,
|
|
348
|
-
mode: 'highest',
|
|
349
|
-
});
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
if (
|
|
354
|
-
nodeIds.length === 0 &&
|
|
355
|
-
sourcePath &&
|
|
356
|
-
Editor.hasPath(editor, sourcePath)
|
|
357
|
-
) {
|
|
358
|
-
Transforms.moveNodes(editor, { at: sourcePath, to: dropPath });
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
Transforms.moveNodes(editor, {
|
|
363
|
-
at: [],
|
|
364
|
-
match: (n) => {
|
|
365
|
-
if (!Editor.isBlock(editor, n as CustomElement)) {
|
|
366
|
-
return false;
|
|
367
|
-
}
|
|
371
|
+
const targetRef = Editor.pathRef(editor, targetNodePath);
|
|
372
|
+
const sourceRefs = paths.map((path) => Editor.pathRef(editor, path));
|
|
368
373
|
|
|
369
|
-
|
|
370
|
-
|
|
374
|
+
Editor.withoutNormalizing(editor, () => {
|
|
375
|
+
try {
|
|
376
|
+
// Keep every move anchored to the target as earlier moves shift paths.
|
|
377
|
+
// Inserting after the target runs in reverse to preserve document order.
|
|
378
|
+
const orderedRefs =
|
|
379
|
+
position === 'top' ? sourceRefs : [...sourceRefs].reverse();
|
|
380
|
+
for (const sourceRef of orderedRefs) {
|
|
381
|
+
const from = sourceRef.current;
|
|
382
|
+
const target = targetRef.current;
|
|
383
|
+
if (!from || !target) continue;
|
|
384
|
+
|
|
385
|
+
let to = position === 'top' ? target : Path.next(target);
|
|
386
|
+
// Slate's sibling destination is the index after removing the source.
|
|
387
|
+
if (Path.isSibling(from, to) && Path.isBefore(from, to)) {
|
|
388
|
+
to = Path.previous(to);
|
|
371
389
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
to: dropPath,
|
|
379
|
-
mode: 'highest',
|
|
380
|
-
});
|
|
390
|
+
Transforms.moveNodes(editor, { at: from, to });
|
|
391
|
+
}
|
|
392
|
+
} finally {
|
|
393
|
+
targetRef.unref();
|
|
394
|
+
for (const ref of sourceRefs) ref.unref();
|
|
395
|
+
}
|
|
381
396
|
});
|
|
382
397
|
Editor.normalize(editor);
|
|
383
398
|
};
|