@use-kona/editor 0.1.29 → 0.1.31

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.
Files changed (27) hide show
  1. package/dist/core/createEditable.js +4 -1
  2. package/dist/plugins/CodeBlockPlugin/styles_module.css +1 -0
  3. package/dist/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.js +123 -0
  4. package/dist/plugins/CollapsibleBlocksPlugin/index.js +2 -0
  5. package/dist/plugins/CollapsibleBlocksPlugin/styles.module.js +8 -0
  6. package/dist/plugins/CollapsibleBlocksPlugin/styles_module.css +38 -0
  7. package/dist/plugins/DnDPlugin/DnDPlugin.js +62 -19
  8. package/dist/plugins/index.js +2 -1
  9. package/dist/src/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.d.ts +14 -0
  10. package/dist/src/plugins/CollapsibleBlocksPlugin/index.d.ts +1 -0
  11. package/dist/src/plugins/DnDPlugin/DnDPlugin.d.ts +2 -1
  12. package/dist/src/plugins/DnDPlugin/types.d.ts +1 -0
  13. package/dist/src/plugins/index.d.ts +1 -0
  14. package/dist/src/types.d.ts +5 -0
  15. package/dist/types.d.ts +1 -0
  16. package/package.json +1 -1
  17. package/src/core/createEditable.tsx +5 -1
  18. package/src/plugins/CodeBlockPlugin/styles.module.css +1 -0
  19. package/src/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.spec.tsx +216 -0
  20. package/src/plugins/CollapsibleBlocksPlugin/CollapsibleBlocksPlugin.tsx +190 -0
  21. package/src/plugins/CollapsibleBlocksPlugin/index.ts +1 -0
  22. package/src/plugins/CollapsibleBlocksPlugin/styles.module.css +37 -0
  23. package/src/plugins/DnDPlugin/DnDPlugin.spec.ts +408 -0
  24. package/src/plugins/DnDPlugin/DnDPlugin.tsx +122 -32
  25. package/src/plugins/DnDPlugin/types.ts +1 -0
  26. package/src/plugins/index.ts +1 -0
  27. package/src/types.ts +5 -0
@@ -0,0 +1,408 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import '@testing-library/jest-dom/vitest';
4
+ import { act, render, waitFor } from '@testing-library/react';
5
+ import { createDragDropManager, type DragDropManager } from 'dnd-core';
6
+ import React from 'react';
7
+ import { DndProvider } from 'react-dnd';
8
+ import type { Editor } from 'slate';
9
+ import { createEditor } from 'slate';
10
+ import { describe, expect, it, vi } from 'vitest';
11
+ import { KonaEditor } from '../../editor';
12
+ import type { IPlugin } from '../../types';
13
+ import { CollapsibleBlocksPlugin } from '../CollapsibleBlocksPlugin';
14
+ import { HeadingsPlugin } from '../HeadingsPlugin';
15
+ import { DnDPlugin, moveNode } from './DnDPlugin';
16
+
17
+ Object.assign(globalThis, { React });
18
+
19
+ const listEditor = () => {
20
+ const editor = createEditor();
21
+ editor.children = [
22
+ {
23
+ type: 'ol',
24
+ nodeId: 'list',
25
+ children: [
26
+ { type: 'li', nodeId: 'first', children: [{ text: 'First' }] },
27
+ { type: 'li', nodeId: 'second', children: [{ text: 'Second' }] },
28
+ ],
29
+ },
30
+ ];
31
+ return editor;
32
+ };
33
+
34
+ const sectionEditor = () => {
35
+ const editor = createEditor();
36
+ editor.children = [
37
+ {
38
+ type: 'heading1',
39
+ nodeId: 'section',
40
+ collapsed: true,
41
+ children: [{ text: 'Section' }],
42
+ },
43
+ { type: 'paragraph', nodeId: 'text', children: [{ text: 'Section text' }] },
44
+ {
45
+ type: 'heading2',
46
+ nodeId: 'nested',
47
+ children: [{ text: 'Nested section' }],
48
+ },
49
+ {
50
+ type: 'paragraph',
51
+ nodeId: 'nested-text',
52
+ children: [{ text: 'Nested text' }],
53
+ },
54
+ {
55
+ type: 'heading1',
56
+ nodeId: 'next',
57
+ children: [{ text: 'Next section' }],
58
+ },
59
+ ];
60
+ return editor;
61
+ };
62
+
63
+ describe('moveNode', () => {
64
+ it('moves a list item before another list item', () => {
65
+ const editor = listEditor();
66
+
67
+ moveNode(editor, [0, 0], ['second'], 'top');
68
+
69
+ expect(editor.children[0].children.map((node) => node.nodeId)).toEqual([
70
+ 'second',
71
+ 'first',
72
+ ]);
73
+ });
74
+
75
+ it('allows dropping at the end of a list', () => {
76
+ const editor = listEditor();
77
+
78
+ moveNode(editor, [0, 1], ['first'], 'bottom');
79
+
80
+ expect(editor.children[0].children.map((node) => node.nodeId)).toEqual([
81
+ 'second',
82
+ 'first',
83
+ ]);
84
+ });
85
+
86
+ it('moves a collapsed section with all of its content', () => {
87
+ const editor = sectionEditor();
88
+
89
+ moveNode(editor, [4], [], 'bottom', undefined, [[0], [1], [2], [3]]);
90
+
91
+ expect(editor.children.map((node) => node.children[0].text)).toEqual([
92
+ 'Next section',
93
+ 'Section',
94
+ 'Section text',
95
+ 'Nested section',
96
+ 'Nested text',
97
+ ]);
98
+ expect(editor.children[1]).toMatchObject({ collapsed: true });
99
+ });
100
+
101
+ it('moves a nested section without crossing the next equal-or-higher boundary', () => {
102
+ const editor = sectionEditor();
103
+
104
+ moveNode(editor, [4], [], 'bottom', undefined, [[2], [3]]);
105
+
106
+ expect(editor.children.map((node) => node.children[0].text)).toEqual([
107
+ 'Section',
108
+ 'Section text',
109
+ 'Next section',
110
+ 'Nested section',
111
+ 'Nested text',
112
+ ]);
113
+ });
114
+
115
+ it('does not move a section when dropped onto one of its own blocks', () => {
116
+ const editor = sectionEditor();
117
+
118
+ moveNode(editor, [2], [], 'top', undefined, [[0], [1], [2], [3]]);
119
+
120
+ expect(editor.children.map((node) => node.children[0].text)).toEqual([
121
+ 'Section',
122
+ 'Section text',
123
+ 'Nested section',
124
+ 'Nested text',
125
+ 'Next section',
126
+ ]);
127
+ });
128
+ });
129
+
130
+ const createDndManager = (): DragDropManager =>
131
+ createDragDropManager(() => ({
132
+ connectDragPreview: () => () => {},
133
+ connectDragSource: () => () => {},
134
+ connectDropTarget: () => () => {},
135
+ profile: () => ({}),
136
+ setup: () => {},
137
+ teardown: () => {},
138
+ }));
139
+
140
+ const getHandlerIds = (
141
+ manager: DragDropManager,
142
+ kind: 'dragSources' | 'dropTargets',
143
+ ) => {
144
+ const registry = manager.getRegistry() as unknown as Record<
145
+ 'dragSources' | 'dropTargets',
146
+ Map<string, unknown>
147
+ >;
148
+ return [...registry[kind].keys()];
149
+ };
150
+
151
+ describe('DnDPlugin', () => {
152
+ it('reorders rendered paragraphs when dropped before another block', async () => {
153
+ const manager = createDndManager();
154
+ const onChange = vi.fn();
155
+ let editor: Editor | undefined;
156
+
157
+ const dndPlugin = new DnDPlugin({
158
+ onDropFiles: () => {},
159
+ renderBlock: ({ dragRef, dropRef, previewRef, props }) =>
160
+ React.createElement(
161
+ 'div',
162
+ {
163
+ ...props.attributes,
164
+ ref: (element) => dropRef(previewRef(element)),
165
+ },
166
+ React.createElement(
167
+ 'button',
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,
186
+ {
187
+ init(value) {
188
+ editor = value;
189
+ return value;
190
+ },
191
+ } satisfies IPlugin,
192
+ ],
193
+ onChange,
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
+ });
203
+
204
+ try {
205
+ const sourceIds = getHandlerIds(manager, 'dragSources');
206
+ const targetIds = getHandlerIds(manager, 'dropTargets');
207
+
208
+ expect(sourceIds).toHaveLength(2);
209
+ expect(targetIds).toHaveLength(2);
210
+
211
+ act(() => {
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 {
225
+ Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
226
+ configurable: true,
227
+ value: originalRect,
228
+ });
229
+ }
230
+
231
+ expect(editor?.children).toEqual([
232
+ expect.objectContaining({
233
+ type: 'paragraph',
234
+ children: [{ text: 'Second' }],
235
+ }),
236
+ expect.objectContaining({
237
+ type: 'paragraph',
238
+ children: [{ text: 'First' }],
239
+ }),
240
+ ]);
241
+ await waitFor(() =>
242
+ expect(onChange).toHaveBeenLastCalledWith(editor?.children),
243
+ );
244
+ });
245
+
246
+ it('moves every block in an expanded collapsible section', async () => {
247
+ const manager = createDndManager();
248
+ const onChange = vi.fn();
249
+ let editor: Editor | undefined;
250
+
251
+ const dndPlugin = new DnDPlugin({
252
+ onDropFiles: () => {},
253
+ renderBlock: ({ dragRef, dropRef, previewRef, props }) =>
254
+ React.createElement(
255
+ 'div',
256
+ {
257
+ ...props.attributes,
258
+ ref: (element) => dropRef(previewRef(element)),
259
+ },
260
+ React.createElement(
261
+ 'button',
262
+ { contentEditable: false, ref: dragRef, type: 'button' },
263
+ 'Drag',
264
+ ),
265
+ props.children,
266
+ ),
267
+ });
268
+
269
+ render(
270
+ React.createElement(
271
+ DndProvider,
272
+ { manager },
273
+ React.createElement(KonaEditor, {
274
+ initialValue: [
275
+ {
276
+ type: HeadingsPlugin.HeadingLevel1,
277
+ children: [{ text: 'Section' }],
278
+ },
279
+ { type: 'paragraph', children: [{ text: 'Section text' }] },
280
+ {
281
+ type: HeadingsPlugin.HeadingLevel2,
282
+ children: [{ text: 'Nested section' }],
283
+ },
284
+ { type: 'paragraph', children: [{ text: 'Nested text' }] },
285
+ {
286
+ type: HeadingsPlugin.HeadingLevel1,
287
+ children: [{ text: 'Next section' }],
288
+ },
289
+ ],
290
+ plugins: [
291
+ new HeadingsPlugin(),
292
+ new CollapsibleBlocksPlugin([
293
+ HeadingsPlugin.HeadingLevel1,
294
+ HeadingsPlugin.HeadingLevel2,
295
+ ]),
296
+ dndPlugin,
297
+ {
298
+ init(value) {
299
+ editor = value;
300
+ return value;
301
+ },
302
+ } satisfies IPlugin,
303
+ ],
304
+ onChange,
305
+ }),
306
+ ),
307
+ );
308
+
309
+ const originalRect = HTMLElement.prototype.getBoundingClientRect;
310
+ Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
311
+ configurable: true,
312
+ value: () => ({ bottom: 100, top: 0 }),
313
+ });
314
+
315
+ try {
316
+ const sourceIds = getHandlerIds(manager, 'dragSources');
317
+ const targetIds = getHandlerIds(manager, 'dropTargets');
318
+
319
+ act(() => {
320
+ manager.getActions().beginDrag([sourceIds[0]], {
321
+ clientOffset: { x: 0, y: 50 },
322
+ getSourceClientOffset: () => ({ x: 0, y: 0 }),
323
+ });
324
+ manager.getActions().publishDragSource();
325
+ manager
326
+ .getActions()
327
+ .hover([targetIds[4]], { clientOffset: { x: 0, y: 100 } });
328
+ manager.getActions().drop();
329
+ manager.getActions().endDrag();
330
+ });
331
+ } finally {
332
+ Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
333
+ configurable: true,
334
+ value: originalRect,
335
+ });
336
+ }
337
+
338
+ expect(editor?.children.map((node) => node.children[0].text)).toEqual([
339
+ 'Next section',
340
+ 'Section',
341
+ 'Section text',
342
+ 'Nested section',
343
+ 'Nested text',
344
+ ]);
345
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
346
+ });
347
+
348
+ it('hides custom drag wrappers for collapsed section content', async () => {
349
+ const manager = createDndManager();
350
+ const dndPlugin = new DnDPlugin({
351
+ onDropFiles: () => {},
352
+ renderBlock: ({ dragRef, dropRef, previewRef, props }) =>
353
+ React.createElement(
354
+ 'div',
355
+ {
356
+ ...props.attributes,
357
+ 'data-dnd-block': (props.element as { nodeId: string }).nodeId,
358
+ ref: (element) => dropRef(previewRef(element)),
359
+ },
360
+ React.createElement(
361
+ 'button',
362
+ { contentEditable: false, ref: dragRef, type: 'button' },
363
+ 'Drag',
364
+ ),
365
+ props.children,
366
+ ),
367
+ });
368
+
369
+ render(
370
+ React.createElement(
371
+ DndProvider,
372
+ { manager },
373
+ React.createElement(KonaEditor, {
374
+ initialValue: [
375
+ {
376
+ type: HeadingsPlugin.HeadingLevel1,
377
+ nodeId: 'section',
378
+ collapsed: true,
379
+ children: [{ text: 'Section' }],
380
+ },
381
+ {
382
+ type: 'paragraph',
383
+ nodeId: 'section-text',
384
+ children: [{ text: 'Section text' }],
385
+ },
386
+ {
387
+ type: HeadingsPlugin.HeadingLevel1,
388
+ nodeId: 'next-section',
389
+ children: [{ text: 'Next section' }],
390
+ },
391
+ ],
392
+ plugins: [
393
+ new HeadingsPlugin(),
394
+ new CollapsibleBlocksPlugin([HeadingsPlugin.HeadingLevel1]),
395
+ dndPlugin,
396
+ ],
397
+ onChange: () => {},
398
+ }),
399
+ ),
400
+ );
401
+
402
+ const hiddenDragBlock = document.querySelector(
403
+ '[data-dnd-block="section-text"]',
404
+ );
405
+
406
+ expect(hiddenDragBlock).not.toBeVisible();
407
+ });
408
+ });
@@ -12,6 +12,10 @@ import {
12
12
  import type { CustomElement } from '../../../types';
13
13
  import { useEditorContext } from '../../provider';
14
14
  import type { IPlugin } from '../../types';
15
+ import {
16
+ getCollapsibleSectionPaths,
17
+ isHiddenByCollapsedSection,
18
+ } from '../CollapsibleBlocksPlugin/CollapsibleBlocksPlugin';
15
19
  import type { EditorDragItem, NodeWithId, Options } from './types';
16
20
  import { getNodesByNodeIds } from './utils';
17
21
 
@@ -49,20 +53,24 @@ export class DnDPlugin implements IPlugin {
49
53
 
50
54
  const customType = options.customTypes?.[props.element.type];
51
55
  const currentElement = props.element as NodeWithId;
56
+ const path = ReactEditor.findPath(editor, props.element);
57
+ const isHidden = isHiddenByCollapsedSection(editor, path);
52
58
 
53
59
  const [, drag, preview] = useDrag({
54
60
  type: customType?.type || DnDPlugin.DND_BLOCK_ELEMENT,
55
61
  item: () => {
56
62
  const selected: Array<NodeWithId> = $selectedNodes;
63
+ const sectionPaths = getCollapsibleSectionPaths(editor, path);
57
64
 
58
65
  /**
59
66
  * We copy nodes that the user is dragging.
60
67
  * If a user drags multiple nodes, and the current node is one of them,
61
68
  * we copy selected nodes. Otherwise, we copy only the current node.
62
69
  */
63
- const nodes =
64
- selected.length > 0 &&
65
- selected.find((node) => node.nodeId === currentElement.nodeId)
70
+ const nodes = sectionPaths
71
+ ? getNodesAtPaths(editor, sectionPaths)
72
+ : selected.length > 0 &&
73
+ selected.find((node) => node.nodeId === currentElement.nodeId)
66
74
  ? getNodesByNodeIds(
67
75
  editor,
68
76
  selected.map((node) => node.nodeId),
@@ -91,6 +99,8 @@ export class DnDPlugin implements IPlugin {
91
99
  source: {
92
100
  editor,
93
101
  documentId: options.documentId,
102
+ path,
103
+ sectionPaths: sectionPaths || undefined,
94
104
  },
95
105
  };
96
106
 
@@ -124,22 +134,8 @@ export class DnDPlugin implements IPlugin {
124
134
  };
125
135
  },
126
136
  hover(_, monitor) {
127
- const element = dropTargetRef.current;
128
- if (!element) return;
129
-
130
- const hoverBoundingRect = element.getBoundingClientRect();
131
- const hoverMiddleY =
132
- (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
133
- const clientOffset = monitor.getClientOffset();
134
- if (!clientOffset) return;
135
-
136
- const hoverClientY = clientOffset.y - hoverBoundingRect.top;
137
-
138
- if (hoverClientY < hoverMiddleY) {
139
- setDropPosition('top');
140
- } else {
141
- setDropPosition('bottom');
142
- }
137
+ const position = getDropPosition(dropTargetRef.current, monitor);
138
+ if (position) setDropPosition(position);
143
139
  },
144
140
  drop: (data, monitor) => {
145
141
  const itemType = monitor.getItemType();
@@ -156,11 +152,12 @@ export class DnDPlugin implements IPlugin {
156
152
 
157
153
  if (!sourceTo) return;
158
154
 
159
- const insertAt = getDropPath(
160
- editor,
161
- sourceTo,
162
- dropPosition || 'bottom',
163
- );
155
+ const position =
156
+ getDropPosition(dropTargetRef.current, monitor) ||
157
+ dropPosition ||
158
+ 'bottom';
159
+
160
+ const insertAt = getDropPath(editor, sourceTo, position);
164
161
 
165
162
  if (!insertAt) return;
166
163
 
@@ -188,15 +185,24 @@ export class DnDPlugin implements IPlugin {
188
185
  return;
189
186
  }
190
187
 
191
- if (!dropPosition || !dragItem.nodes.length) {
188
+ if (!dragItem.nodes.length) {
192
189
  return;
193
190
  }
194
191
 
195
- const nodeIds = dragItem.nodes.map((node) => node.nodeId);
192
+ const nodeIds = dragItem.nodes
193
+ .map((node) => node.nodeId)
194
+ .filter((nodeId): nodeId is string => typeof nodeId === 'string');
196
195
 
197
196
  if (isSameDocument || isSameEditor) {
198
197
  if (!isCopying.current) {
199
- moveNode(editor, sourceTo, nodeIds, dropPosition);
198
+ moveNode(
199
+ editor,
200
+ sourceTo,
201
+ nodeIds,
202
+ position,
203
+ dragItem.source.path,
204
+ dragItem.source.sectionPaths,
205
+ );
200
206
  } else {
201
207
  if (!generateId) {
202
208
  return;
@@ -214,7 +220,11 @@ export class DnDPlugin implements IPlugin {
214
220
 
215
221
  insertNodes(editor, insertAt, clonedNodes);
216
222
  if (!isCopying.current && sourceEditor) {
217
- removeNodes(sourceEditor, nodeIds);
223
+ removeNodes(
224
+ sourceEditor,
225
+ nodeIds,
226
+ dragItem.source.sectionPaths,
227
+ );
218
228
  }
219
229
  }
220
230
  selectedNodes.set([]);
@@ -246,7 +256,7 @@ export class DnDPlugin implements IPlugin {
246
256
  }
247
257
 
248
258
  return (
249
- <div>
259
+ <div hidden={isHidden}>
250
260
  {options.renderBlock?.({
251
261
  props,
252
262
  dragRef: drag,
@@ -289,20 +299,66 @@ const getDropPath = (
289
299
  }
290
300
  };
291
301
 
292
- const moveNode = (
302
+ const getDropPosition = (
303
+ element: HTMLElement | null,
304
+ monitor: { getClientOffset: () => { y: number } | null },
305
+ ) => {
306
+ if (!element) return null;
307
+
308
+ const clientOffset = monitor.getClientOffset();
309
+ if (!clientOffset) return null;
310
+
311
+ const rect = element.getBoundingClientRect();
312
+ return clientOffset.y - rect.top < (rect.bottom - rect.top) / 2
313
+ ? 'top'
314
+ : 'bottom';
315
+ };
316
+
317
+ export const moveNode = (
293
318
  editor: Editor,
294
319
  targetNodePath: Path,
295
320
  nodeIds: string[],
296
321
  position: 'top' | 'bottom',
322
+ sourcePath?: Path,
323
+ sectionPaths?: Path[],
297
324
  ) => {
298
325
  const dropPath = getDropPath(editor, targetNodePath, position);
299
326
 
300
327
  if (!dropPath) return;
301
- if (!Editor.hasPath(editor, dropPath)) {
328
+ if (!isValidInsertPath(editor, dropPath)) {
329
+ return;
330
+ }
331
+
332
+ if (sectionPaths?.some((path) => Path.equals(path, targetNodePath))) {
302
333
  return;
303
334
  }
304
335
 
305
336
  Editor.withoutNormalizing(editor, () => {
337
+ if (sectionPaths?.length) {
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
+
306
362
  Transforms.moveNodes(editor, {
307
363
  at: [],
308
364
  match: (n) => {
@@ -326,14 +382,48 @@ const moveNode = (
326
382
  Editor.normalize(editor);
327
383
  };
328
384
 
385
+ const isValidInsertPath = (editor: Editor, path: Path) => {
386
+ const parentPath = Path.parent(path);
387
+ const index = path[path.length - 1];
388
+
389
+ if (index === undefined || !Editor.hasPath(editor, parentPath)) {
390
+ return false;
391
+ }
392
+
393
+ const [parent] = Editor.node(editor, parentPath);
394
+ return 'children' in parent && index <= parent.children.length;
395
+ };
396
+
329
397
  const insertNodes = (editor: Editor, path: Path, nodes: NodeWithId[]) => {
330
398
  Editor.withoutNormalizing(editor, () => {
331
399
  Transforms.insertNodes(editor, nodes, { at: path });
332
400
  });
333
401
  };
334
402
 
335
- const removeNodes = (editor: Editor, nodeIds: string[]) => {
403
+ const getNodesAtPaths = (editor: Editor, paths: Path[]) =>
404
+ paths
405
+ .filter((path) => Editor.hasPath(editor, path))
406
+ .map((path) => structuredClone(Editor.node(editor, path)[0] as NodeWithId));
407
+
408
+ const removeNodes = (
409
+ editor: Editor,
410
+ nodeIds: string[],
411
+ sectionPaths?: Path[],
412
+ ) => {
336
413
  try {
414
+ if (sectionPaths?.length) {
415
+ const paths = sectionPaths
416
+ .filter((path) => Editor.hasPath(editor, path))
417
+ .sort((left, right) => Path.compare(right, left));
418
+
419
+ Editor.withoutNormalizing(editor, () => {
420
+ paths.forEach((path) => {
421
+ Transforms.removeNodes(editor, { at: path });
422
+ });
423
+ });
424
+ return;
425
+ }
426
+
337
427
  Transforms.removeNodes(editor, {
338
428
  at: [],
339
429
  match: (n) => {
@@ -73,6 +73,7 @@ export type EditorInnerDragItem<TItem = unknown> = {
73
73
  */
74
74
  documentId?: string | null;
75
75
  path?: Path;
76
+ sectionPaths?: Path[];
76
77
  };
77
78
  data?: Record<string, unknown>;
78
79
  };
@@ -2,6 +2,7 @@ export { AttachmentsPlugin } from './AttachmentsPlugin';
2
2
  export { BasicFormattingPlugin } from './BasicFormattingPlugin';
3
3
  export { BreaksPlugin } from './BreaksPlugin';
4
4
  export { CodeBlockPlugin, type CodeElement } from './CodeBlockPlugin';
5
+ export { CollapsibleBlocksPlugin } from './CollapsibleBlocksPlugin';
5
6
  export {
6
7
  type Command,
7
8
  type CommandPathEntry,
package/src/types.ts CHANGED
@@ -21,6 +21,11 @@ export interface IPlugin<
21
21
  leafs?: Leaf<TEditor, TLeaf>[];
22
22
  hotkeys?: Hotkey[];
23
23
 
24
+ /**
25
+ * Render block wrappers from lower to higher priority. Plugins with the same
26
+ * priority retain the order supplied to KonaEditor.
27
+ */
28
+ renderBlockPriority?: number;
24
29
  renderBlock?: (props: RenderElementProps) => ReactElement | null;
25
30
  renderLeaf?: (props: RenderLeafProps) => ReactElement | null;
26
31