@use-kona/editor 0.1.24 → 0.1.26

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 (38) hide show
  1. package/dist/core/createEditable.js +146 -15
  2. package/dist/core/createEditor.js +1 -1
  3. package/dist/core/styles.module.js +3 -1
  4. package/dist/core/styles_module.css +13 -0
  5. package/dist/elements/Block.js +25 -0
  6. package/dist/index.js +2 -1
  7. package/dist/plugins/CodeBlockPlugin/CodeBlockPlugin.js +1 -1
  8. package/dist/plugins/DnDPlugin/DnDPlugin.js +93 -45
  9. package/dist/plugins/DnDPlugin/types.js +2 -0
  10. package/dist/plugins/HeadingsPlugin/HeadingsPlugin.js +1 -1
  11. package/dist/plugins/ListsPlugin/ListsPlugin.js +5 -3
  12. package/dist/plugins/NodeIdPlugin/NodeIdPlugin.js +6 -4
  13. package/dist/provider.js +15 -0
  14. package/dist/src/core/createEditable.d.ts +1 -1
  15. package/dist/src/elements/Block.d.ts +2 -0
  16. package/dist/src/index.d.ts +1 -0
  17. package/dist/src/plugins/DnDPlugin/DnDPlugin.d.ts +0 -7
  18. package/dist/src/plugins/DnDPlugin/types.d.ts +29 -15
  19. package/dist/src/plugins/ListsPlugin/ListsPlugin.d.ts +1 -0
  20. package/dist/src/plugins/NodeIdPlugin/NodeIdPlugin.d.ts +1 -0
  21. package/dist/src/provider.d.ts +8 -0
  22. package/dist/src/types.d.ts +4 -0
  23. package/package.json +9 -8
  24. package/src/core/createEditable.tsx +170 -18
  25. package/src/core/createEditor.ts +1 -1
  26. package/src/core/styles.module.css +12 -0
  27. package/src/editor.tsx +4 -3
  28. package/src/elements/Block.tsx +31 -0
  29. package/src/index.ts +1 -0
  30. package/src/plugins/CodeBlockPlugin/CodeBlockPlugin.tsx +1 -3
  31. package/src/plugins/DnDPlugin/DnDPlugin.tsx +147 -62
  32. package/src/plugins/DnDPlugin/types.ts +35 -20
  33. package/src/plugins/HeadingsPlugin/HeadingsPlugin.tsx +1 -2
  34. package/src/plugins/ListsPlugin/ListsPlugin.spec.tsx +110 -14
  35. package/src/plugins/ListsPlugin/ListsPlugin.tsx +17 -6
  36. package/src/plugins/NodeIdPlugin/NodeIdPlugin.ts +9 -7
  37. package/src/provider.tsx +28 -0
  38. package/src/types.ts +5 -0
@@ -1,10 +1,8 @@
1
1
  import { useStore } from '@nanostores/react';
2
- import { nanoid } from 'nanoid';
3
- import { type MapStore, map } from 'nanostores';
4
2
  import { useEffect, useRef, useState } from 'react';
5
3
  import { type ConnectDropTarget, useDrag, useDrop } from 'react-dnd';
6
4
  import { NativeTypes } from 'react-dnd-html5-backend';
7
- import { Editor, Path, Transforms } from 'slate';
5
+ import { Editor, Element, Path, Transforms } from 'slate';
8
6
  import {
9
7
  ReactEditor,
10
8
  type RenderElementProps,
@@ -12,44 +10,18 @@ import {
12
10
  useSlate,
13
11
  } from 'slate-react';
14
12
  import type { CustomElement } from '../../../types';
13
+ import { useEditorContext } from '../../provider';
15
14
  import type { IPlugin } from '../../types';
16
15
  import type { EditorDragItem, NodeWithId, Options } from './types';
17
16
  import { getNodesByNodeIds } from './utils';
18
17
 
19
18
  export class DnDPlugin implements IPlugin {
20
- store: MapStore<{ selected: Set<string> }>;
21
- editors: WeakMap<Editor, string>;
22
-
23
- constructor(private options: Options) {
24
- this.store = map({
25
- selected: new Set(),
26
- });
27
- this.editors = new WeakMap();
28
- }
19
+ constructor(private options: Options) {}
29
20
 
30
21
  init(editor: Editor) {
31
- this.editors.set(editor, nanoid());
32
-
33
22
  return editor;
34
23
  }
35
24
 
36
- getEditorId(editor: Editor) {
37
- return this.editors.get(editor);
38
- }
39
-
40
- handleToggleSelected = (nodeId: string) => {
41
- const selected = new Set(this.store.get().selected);
42
- if (selected.has(nodeId)) {
43
- selected.delete(nodeId);
44
- } else {
45
- selected.add(nodeId);
46
- }
47
-
48
- this.store.set({
49
- selected,
50
- });
51
- };
52
-
53
25
  static DND_BLOCK_ELEMENT = 'block';
54
26
 
55
27
  handlers = {
@@ -61,9 +33,12 @@ export class DnDPlugin implements IPlugin {
61
33
  renderBlock = (props: RenderElementProps) => {
62
34
  const editor = useSlate();
63
35
 
64
- const $store = useStore(this.store);
36
+ const { selectedNodes } = useEditorContext();
37
+ const $selectedNodes = useStore(selectedNodes);
65
38
 
39
+ const isCopying = useRef(false);
66
40
  const options = this.options;
41
+ const generateId = this.options?.nodeIdPlugin?.generateId;
67
42
  const isReadOnly = useReadOnly();
68
43
 
69
44
  const [dropPosition, setDropPosition] = useState<'top' | 'bottom' | null>(
@@ -76,9 +51,9 @@ export class DnDPlugin implements IPlugin {
76
51
  const currentElement = props.element as NodeWithId;
77
52
 
78
53
  const [, drag, preview] = useDrag({
79
- type: DnDPlugin.DND_BLOCK_ELEMENT,
54
+ type: customType?.type || DnDPlugin.DND_BLOCK_ELEMENT,
80
55
  item: () => {
81
- const selected = Array.from($store.selected.values());
56
+ const selected: Array<NodeWithId> = $selectedNodes;
82
57
 
83
58
  /**
84
59
  * We copy nodes that the user is dragging.
@@ -86,10 +61,19 @@ export class DnDPlugin implements IPlugin {
86
61
  * we copy selected nodes. Otherwise, we copy only the current node.
87
62
  */
88
63
  const nodes =
89
- selected.length > 0 && selected.includes(currentElement.nodeId)
90
- ? getNodesByNodeIds(editor, selected)
64
+ selected.length > 0 &&
65
+ selected.find((node) => node.nodeId === currentElement.nodeId)
66
+ ? getNodesByNodeIds(
67
+ editor,
68
+ selected.map((node) => node.nodeId),
69
+ )
91
70
  : [structuredClone(props.element as NodeWithId)];
92
71
 
72
+ const item =
73
+ this.options.customTypes?.[currentElement.type]?.getData?.(
74
+ currentElement,
75
+ );
76
+
93
77
  const items = nodes
94
78
  .map((node) => {
95
79
  if (this.options.customTypes?.[node.type]) {
@@ -100,10 +84,12 @@ export class DnDPlugin implements IPlugin {
100
84
  .filter(Boolean);
101
85
 
102
86
  const dragItem: EditorDragItem = {
87
+ kind: 'inner',
103
88
  nodes,
89
+ item,
104
90
  items,
105
91
  source: {
106
- editorId: this.getEditorId(editor),
92
+ editor,
107
93
  documentId: options.documentId,
108
94
  },
109
95
  };
@@ -112,7 +98,7 @@ export class DnDPlugin implements IPlugin {
112
98
  },
113
99
  canDrag: !isReadOnly,
114
100
  end: () => {
115
- $store.selected.clear();
101
+ selectedNodes.set([]);
116
102
  },
117
103
  });
118
104
 
@@ -120,7 +106,11 @@ export class DnDPlugin implements IPlugin {
120
106
  (customType) => customType.type,
121
107
  );
122
108
 
123
- const [{ isOver }, drop] = useDrop({
109
+ const [{ isOver }, drop] = useDrop<
110
+ EditorDragItem,
111
+ void,
112
+ { isOver: boolean }
113
+ >({
124
114
  accept: [
125
115
  DnDPlugin.DND_BLOCK_ELEMENT,
126
116
  NativeTypes.FILE,
@@ -151,8 +141,16 @@ export class DnDPlugin implements IPlugin {
151
141
  setDropPosition('bottom');
152
142
  }
153
143
  },
154
- drop(item, monitor) {
144
+ drop: (data, monitor) => {
155
145
  const itemType = monitor.getItemType();
146
+ const sourceEditor = data.kind === 'inner' ? data.source?.editor : null;
147
+
148
+ const isSameDocument =
149
+ data.kind === 'inner' &&
150
+ data.source?.documentId === this.options.documentId &&
151
+ this.options.documentId !== undefined;
152
+ const isSameEditor =
153
+ data.kind === 'inner' && data.source?.editor === editor;
156
154
 
157
155
  const sourceTo = ReactEditor.findPath(editor, props.element);
158
156
 
@@ -169,8 +167,8 @@ export class DnDPlugin implements IPlugin {
169
167
  const customDropHandler =
170
168
  itemType && options.customDropHandlers?.[itemType];
171
169
 
172
- if (customDropHandler) {
173
- customDropHandler.onDrop({ editor, item, insertAt });
170
+ if (customDropHandler && data.kind === 'outer') {
171
+ customDropHandler.onDrop({ editor, data, insertAt });
174
172
  return;
175
173
  }
176
174
 
@@ -178,12 +176,17 @@ export class DnDPlugin implements IPlugin {
178
176
  case NativeTypes.FILE:
179
177
  options.onDropFiles(
180
178
  editor,
181
- (item as { files: File[] }).files,
179
+ (data as unknown as { files: File[] }).files,
182
180
  insertAt,
183
181
  );
184
182
  break;
185
183
  default: {
186
- const dragItem = item as EditorDragItem;
184
+ const dragItem = data as EditorDragItem;
185
+
186
+ // We don't know what "default" behavior for the outer items is
187
+ if (dragItem.kind === 'outer') {
188
+ return;
189
+ }
187
190
 
188
191
  if (!dropPosition || !dragItem.nodes.length) {
189
192
  return;
@@ -191,12 +194,35 @@ export class DnDPlugin implements IPlugin {
191
194
 
192
195
  const nodeIds = dragItem.nodes.map((node) => node.nodeId);
193
196
 
194
- moveNode(editor, sourceTo, nodeIds, dropPosition);
195
- $store.selected.clear();
197
+ if (isSameDocument || isSameEditor) {
198
+ if (!isCopying.current) {
199
+ moveNode(editor, sourceTo, nodeIds, dropPosition);
200
+ } else {
201
+ if (!generateId) {
202
+ return;
203
+ }
204
+
205
+ const clonedNodes = cloneWithNewIds(dragItem.nodes, generateId);
206
+ insertNodes(editor, insertAt, clonedNodes);
207
+ }
208
+ } else {
209
+ if (!generateId) {
210
+ return;
211
+ }
212
+
213
+ const clonedNodes = cloneWithNewIds(dragItem.nodes, generateId);
214
+
215
+ insertNodes(editor, insertAt, clonedNodes);
216
+ if (!isCopying.current && sourceEditor) {
217
+ removeNodes(sourceEditor, nodeIds);
218
+ }
219
+ }
220
+ selectedNodes.set([]);
196
221
  break;
197
222
  }
198
223
  }
199
224
 
225
+ isCopying.current = false;
200
226
  setDropPosition(null);
201
227
  },
202
228
  });
@@ -219,22 +245,32 @@ export class DnDPlugin implements IPlugin {
219
245
  return props.children;
220
246
  }
221
247
 
222
- return options.renderBlock?.({
223
- props,
224
- dragRef: drag,
225
- dropRef: connectDropRef,
226
- previewRef: preview,
227
- position: dropPosition,
228
- selected:
229
- typeof currentElement.nodeId === 'string'
230
- ? $store.selected.has(currentElement.nodeId)
231
- : false,
232
- onToggleSelected: () => {
233
- if (typeof currentElement.nodeId === 'string') {
234
- this.handleToggleSelected(currentElement.nodeId);
235
- }
236
- },
237
- });
248
+ return (
249
+ <div>
250
+ {options.renderBlock?.({
251
+ props,
252
+ dragRef: drag,
253
+ dropRef: connectDropRef,
254
+ previewRef: preview,
255
+ position: dropPosition,
256
+ onNativeDrop: (event) => {
257
+ isCopying.current = event.altKey;
258
+ },
259
+ onToggleSelected: (event) => {
260
+ event.stopPropagation();
261
+ if ($selectedNodes.includes(props.element)) {
262
+ selectedNodes.set(
263
+ [...$selectedNodes].filter(
264
+ (element) => element !== props.element,
265
+ ),
266
+ );
267
+ } else {
268
+ selectedNodes.set([...$selectedNodes, props.element]);
269
+ }
270
+ },
271
+ })}
272
+ </div>
273
+ );
238
274
  };
239
275
  }
240
276
 
@@ -289,3 +325,52 @@ const moveNode = (
289
325
  });
290
326
  Editor.normalize(editor);
291
327
  };
328
+
329
+ const insertNodes = (editor: Editor, path: Path, nodes: NodeWithId[]) => {
330
+ Editor.withoutNormalizing(editor, () => {
331
+ Transforms.insertNodes(editor, nodes, { at: path });
332
+ });
333
+ };
334
+
335
+ const removeNodes = (editor: Editor, nodeIds: string[]) => {
336
+ try {
337
+ Transforms.removeNodes(editor, {
338
+ at: [],
339
+ match: (n) => {
340
+ if (!Element.isElement(n)) {
341
+ return false;
342
+ }
343
+
344
+ if (!Editor.isBlock(editor, n)) {
345
+ return false;
346
+ }
347
+
348
+ const node = n as NodeWithId;
349
+ return typeof node.nodeId === 'string' && nodeIds.includes(node.nodeId);
350
+ },
351
+ mode: 'highest',
352
+ });
353
+ } catch {
354
+ return;
355
+ }
356
+ };
357
+
358
+ const cloneWithNewIds = (
359
+ nodes: NodeWithId[],
360
+ generateId: () => string,
361
+ ): NodeWithId[] => {
362
+ const copy = structuredClone(nodes);
363
+
364
+ const assignId = (node: NodeWithId): void => {
365
+ node.nodeId = generateId();
366
+ node.children.forEach((node) => {
367
+ if ('children' in node) {
368
+ assignId(node);
369
+ }
370
+ });
371
+ };
372
+
373
+ copy.forEach(assignId);
374
+
375
+ return copy;
376
+ };
@@ -1,4 +1,3 @@
1
- import { type MapStore, map } from 'nanostores';
2
1
  import type React from 'react';
3
2
  import type {
4
3
  ConnectDragPreview,
@@ -7,15 +6,20 @@ import type {
7
6
  } from 'react-dnd';
8
7
  import type { Editor, Path } from 'slate';
9
8
  import type { RenderElementProps } from 'slate-react';
10
- import type { CustomElement } from '../../../types';
9
+ import type { CustomElement, CustomText } from '../../../types';
10
+ import type { NodeIdPlugin } from '../NodeIdPlugin';
11
11
 
12
- type DropParams = {
13
- editor: Editor;
14
- item: unknown;
12
+ export type NodeWithId = Omit<CustomElement, 'children'> & {
13
+ nodeId: string;
14
+ children: (NodeWithId | CustomText)[];
15
15
  };
16
16
 
17
- export type NodeWithId = CustomElement & {
18
- nodeId: string;
17
+ export const isNodeWithId = (node: unknown): node is NodeWithId =>
18
+ typeof node === 'object' && node !== null && 'nodeId' in node;
19
+
20
+ export type CustomDropHandler<T = unknown> = {
21
+ type: string | symbol;
22
+ onDrop: (context: CustomDropContext<T>) => DropResult;
19
23
  };
20
24
 
21
25
  export type Options = {
@@ -28,8 +32,8 @@ export type Options = {
28
32
  dropRef: ConnectDropTarget;
29
33
  previewRef: ConnectDragPreview;
30
34
  position: 'top' | 'bottom' | null;
31
- selected?: boolean;
32
- onToggleSelected: () => void;
35
+ onNativeDrop?: (event: React.DragEvent<HTMLDivElement>) => void;
36
+ onToggleSelected?: (event: React.MouseEvent<HTMLDivElement>) => void;
33
37
  }) => React.ReactNode;
34
38
  ignoreNodes?: string[];
35
39
  customTypes?: {
@@ -39,28 +43,26 @@ export type Options = {
39
43
  };
40
44
  };
41
45
  customDropHandlers?: {
42
- [type: string | symbol]: {
43
- type: string | symbol;
44
- onDrop: <T = unknown>(context: CustomDropContext<T>) => DropResult;
45
- };
46
+ [type: string | symbol]: CustomDropHandler;
46
47
  };
48
+ nodeIdPlugin?: NodeIdPlugin;
47
49
  };
48
50
 
49
- type CustomDropContext<T = unknown> = {
51
+ export type CustomDropContext<TItem = unknown> = {
50
52
  editor: Editor;
51
- item: T;
53
+ data: EditorOuterDragItem<TItem>;
52
54
  insertAt: Path;
53
55
  };
54
56
 
55
57
  type DropResult = 'handled' | 'ignored';
56
58
 
57
- /**
58
- * We're using this type to represent a drag item that is being dragged from the editor.
59
- */
60
- export type EditorDragItem = {
59
+ export type EditorInnerDragItem<TItem = unknown> = {
60
+ kind: 'inner';
61
61
  nodes: NodeWithId[];
62
- items?: unknown[];
62
+ item?: TItem;
63
+ items?: TItem[];
63
64
  source: {
65
+ editor: Editor;
64
66
  /**
65
67
  * Different editors have different unique identifiers.
66
68
  */
@@ -74,3 +76,16 @@ export type EditorDragItem = {
74
76
  };
75
77
  data?: Record<string, unknown>;
76
78
  };
79
+
80
+ export type EditorOuterDragItem<TItem = unknown> = {
81
+ kind: 'outer';
82
+ item: TItem;
83
+ items: TItem[];
84
+ };
85
+
86
+ /**
87
+ * We're using this type to represent a drag item that is being dragged from the editor.
88
+ */
89
+ export type EditorDragItem<TItem = unknown> =
90
+ | EditorInnerDragItem<TItem>
91
+ | EditorOuterDragItem<TItem>;
@@ -124,8 +124,7 @@ const isBlockActive = (editor, type) => {
124
124
  const [match] = Array.from(
125
125
  Editor.nodes(editor, {
126
126
  at: Editor.unhangRange(editor, selection),
127
- match: (node) =>
128
- !Editor.isEditor(node) && Element.isElement(node) && node.type === type,
127
+ match: (node) => Element.isElement(node) && node.type === type,
129
128
  }),
130
129
  );
131
130
 
@@ -1,35 +1,44 @@
1
1
  /** @jsxRuntime classic */
2
2
  /** @jsx jsx */
3
3
 
4
+ import type { KeyboardEvent, ReactNode } from 'react';
5
+ import { Editor } from 'slate';
4
6
  import {
5
7
  createHyperscript,
6
8
  createText,
7
9
  type HyperscriptShorthands,
8
10
  } from 'slate-hyperscript';
9
- import { describe, expect, it } from 'vitest';
10
- import { Editor } from 'slate';
11
+ import { describe, expect, it, vi } from 'vitest';
11
12
  import { createEditor } from '../../core/createEditor';
12
13
  import { ListsPlugin } from './ListsPlugin';
13
14
 
14
-
15
15
  const elements: HyperscriptShorthands = {
16
16
  paragraph: { type: 'paragraph' },
17
17
  numberedList: { type: ListsPlugin.NUMBERED_LIST_ELEMENT },
18
18
  bulletedList: { type: ListsPlugin.BULLETED_LIST_ELEMENT },
19
19
  listItem: { type: ListsPlugin.LIST_ITEM_ELEMENT },
20
+ taskItem: { type: 'task-item' },
20
21
  };
21
22
 
22
23
  const jsx = createHyperscript({ elements, creators: { text: createText } });
23
24
 
24
25
  jsx;
25
26
 
26
- const createEditorWithPlugin = (children: any) => {
27
+ const createEditorAndPlugin = (
28
+ children: ReactNode,
29
+ options: ConstructorParameters<typeof ListsPlugin>[0] = {},
30
+ ) => {
27
31
  const editorState = <editor>{children}</editor>;
28
- const editor = createEditor([new ListsPlugin()])();
32
+ const plugin = new ListsPlugin(options);
33
+ const editor = createEditor([plugin])();
29
34
  editor.children = editorState.children;
30
35
  editor.selection = editorState.selection;
31
36
 
32
- return editor;
37
+ return { editor, plugin };
38
+ };
39
+
40
+ const createEditorWithPlugin = (children: ReactNode) => {
41
+ return createEditorAndPlugin(children).editor;
33
42
  };
34
43
 
35
44
  describe('ListsPlugin', () => {
@@ -128,13 +137,19 @@ describe('ListsPlugin', () => {
128
137
  {
129
138
  type: ListsPlugin.NUMBERED_LIST_ELEMENT,
130
139
  children: [
131
- { type: ListsPlugin.LIST_ITEM_ELEMENT, children: [{ text: 'first' }] },
140
+ {
141
+ type: ListsPlugin.LIST_ITEM_ELEMENT,
142
+ children: [{ text: 'first' }],
143
+ },
132
144
  ],
133
145
  },
134
146
  {
135
147
  type: ListsPlugin.NUMBERED_LIST_ELEMENT,
136
148
  children: [
137
- { type: ListsPlugin.LIST_ITEM_ELEMENT, children: [{ text: 'second' }] },
149
+ {
150
+ type: ListsPlugin.LIST_ITEM_ELEMENT,
151
+ children: [{ text: 'second' }],
152
+ },
138
153
  ],
139
154
  },
140
155
  ];
@@ -146,8 +161,14 @@ describe('ListsPlugin', () => {
146
161
  {
147
162
  type: ListsPlugin.NUMBERED_LIST_ELEMENT,
148
163
  children: [
149
- { type: ListsPlugin.LIST_ITEM_ELEMENT, children: [{ text: 'first' }] },
150
- { type: ListsPlugin.LIST_ITEM_ELEMENT, children: [{ text: 'second' }] },
164
+ {
165
+ type: ListsPlugin.LIST_ITEM_ELEMENT,
166
+ children: [{ text: 'first' }],
167
+ },
168
+ {
169
+ type: ListsPlugin.LIST_ITEM_ELEMENT,
170
+ children: [{ text: 'second' }],
171
+ },
151
172
  ],
152
173
  },
153
174
  ];
@@ -166,13 +187,19 @@ describe('ListsPlugin', () => {
166
187
  {
167
188
  type: ListsPlugin.BULLETED_LIST_ELEMENT,
168
189
  children: [
169
- { type: ListsPlugin.LIST_ITEM_ELEMENT, children: [{ text: 'bullet' }] },
190
+ {
191
+ type: ListsPlugin.LIST_ITEM_ELEMENT,
192
+ children: [{ text: 'bullet' }],
193
+ },
170
194
  ],
171
195
  },
172
196
  {
173
197
  type: ListsPlugin.NUMBERED_LIST_ELEMENT,
174
198
  children: [
175
- { type: ListsPlugin.LIST_ITEM_ELEMENT, children: [{ text: 'number' }] },
199
+ {
200
+ type: ListsPlugin.LIST_ITEM_ELEMENT,
201
+ children: [{ text: 'number' }],
202
+ },
176
203
  ],
177
204
  },
178
205
  ];
@@ -184,13 +211,19 @@ describe('ListsPlugin', () => {
184
211
  {
185
212
  type: ListsPlugin.BULLETED_LIST_ELEMENT,
186
213
  children: [
187
- { type: ListsPlugin.LIST_ITEM_ELEMENT, children: [{ text: 'bullet' }] },
214
+ {
215
+ type: ListsPlugin.LIST_ITEM_ELEMENT,
216
+ children: [{ text: 'bullet' }],
217
+ },
188
218
  ],
189
219
  },
190
220
  {
191
221
  type: ListsPlugin.NUMBERED_LIST_ELEMENT,
192
222
  children: [
193
- { type: ListsPlugin.LIST_ITEM_ELEMENT, children: [{ text: 'number' }] },
223
+ {
224
+ type: ListsPlugin.LIST_ITEM_ELEMENT,
225
+ children: [{ text: 'number' }],
226
+ },
194
227
  ],
195
228
  },
196
229
  ];
@@ -242,4 +275,67 @@ describe('ListsPlugin', () => {
242
275
 
243
276
  expect(editor.children).toEqual(output);
244
277
  });
278
+
279
+ it('should turn first-level list item into paragraph on Backspace at start', () => {
280
+ const { editor, plugin } = createEditorAndPlugin(
281
+ <bulletedList>
282
+ <listItem>
283
+ <cursor />
284
+ <text>Hello world</text>
285
+ </listItem>
286
+ </bulletedList>,
287
+ );
288
+ const event = {
289
+ key: 'Backspace',
290
+ preventDefault: vi.fn(),
291
+ } as unknown as KeyboardEvent;
292
+
293
+ plugin.handlers.onKeyDown(event, editor);
294
+
295
+ const output = (
296
+ <editor>
297
+ <paragraph>
298
+ <text>Hello world</text>
299
+ </paragraph>
300
+ </editor>
301
+ );
302
+
303
+ expect(event.preventDefault).toHaveBeenCalledOnce();
304
+ expect(editor.children).toEqual(output.children);
305
+ });
306
+
307
+ it('should ignore configured list item types for Backspace conversion', () => {
308
+ const { editor, plugin } = createEditorAndPlugin(
309
+ <bulletedList>
310
+ <taskItem>
311
+ <cursor />
312
+ <text>Hello world</text>
313
+ </taskItem>
314
+ </bulletedList>,
315
+ {
316
+ ignoreBackspaceTypes: ['task-item'],
317
+ listItemTypes: ['task-item'],
318
+ },
319
+ );
320
+ const event = {
321
+ key: 'Backspace',
322
+ preventDefault: vi.fn(),
323
+ } as unknown as KeyboardEvent;
324
+
325
+ plugin.handlers.onKeyDown(event, editor);
326
+
327
+ const output = (
328
+ <editor>
329
+ <bulletedList>
330
+ <taskItem>
331
+ <text />
332
+ <text>Hello world</text>
333
+ </taskItem>
334
+ </bulletedList>
335
+ </editor>
336
+ );
337
+
338
+ expect(event.preventDefault).not.toHaveBeenCalled();
339
+ expect(editor.children).toEqual(output.children);
340
+ });
245
341
  });
@@ -17,6 +17,7 @@ import styles from './styles.module.css';
17
17
  type Options = {
18
18
  listTypes?: string[];
19
19
  listItemTypes?: string[];
20
+ ignoreBackspaceTypes?: string[];
20
21
  };
21
22
 
22
23
  export class ListsPlugin implements IPlugin {
@@ -27,9 +28,14 @@ export class ListsPlugin implements IPlugin {
27
28
  static LIST_ITEM_ELEMENT = 'li';
28
29
 
29
30
  constructor(options: Options = {}) {
30
- const { listTypes = [], listItemTypes = [] } = options;
31
+ const {
32
+ ignoreBackspaceTypes = [],
33
+ listTypes = [],
34
+ listItemTypes = [],
35
+ } = options;
31
36
 
32
37
  this.options = {
38
+ ignoreBackspaceTypes,
33
39
  listTypes: [
34
40
  ListsPlugin.BULLETED_LIST_ELEMENT,
35
41
  ListsPlugin.NUMBERED_LIST_ELEMENT,
@@ -96,7 +102,10 @@ export class ListsPlugin implements IPlugin {
96
102
  }
97
103
 
98
104
  /* Merge adjacent lists */
99
- if (this.isList(editor, node as CustomElement) && Path.hasPrevious(path)) {
105
+ if (
106
+ this.isList(editor, node as CustomElement) &&
107
+ Path.hasPrevious(path)
108
+ ) {
100
109
  const prevPath = Path.previous(path);
101
110
  const prevNode = Node.get(editor, prevPath) as CustomElement;
102
111
 
@@ -236,9 +245,14 @@ export class ListsPlugin implements IPlugin {
236
245
  if (
237
246
  selection &&
238
247
  match &&
248
+ !this.options.ignoreBackspaceTypes?.includes(
249
+ (match[0] as CustomElement).type,
250
+ ) &&
239
251
  this.getListItemDepth(editor, match[1]) === 1 &&
240
252
  Editor.isStart(editor, selection.anchor, match[1])
241
253
  ) {
254
+ event.preventDefault();
255
+
242
256
  Transforms.unwrapNodes(editor, {
243
257
  match: (n) => this.isList(editor, n as CustomElement),
244
258
  split: true,
@@ -331,10 +345,7 @@ export class ListsPlugin implements IPlugin {
331
345
  const [match] = Array.from(
332
346
  Editor.nodes(editor, {
333
347
  at: Editor.unhangRange(editor, selection),
334
- match: (node) =>
335
- !Editor.isEditor(node) &&
336
- Element.isElement(node) &&
337
- node.type === type,
348
+ match: (node) => Element.isElement(node) && node.type === type,
338
349
  }),
339
350
  );
340
351